Merge "Upgrade ecj to 3.25.0"
diff --git a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF
index defa710..39af83d 100644
--- a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF
+++ b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF
@@ -59,6 +59,8 @@
  org.apache.sshd.common.helpers;version="[2.6.0,2.7.0)",
  org.apache.sshd.common.io;version="[2.6.0,2.7.0)",
  org.apache.sshd.common.kex;version="[2.6.0,2.7.0)",
+ org.apache.sshd.common.kex.extension;version="[2.6.0,2.7.0)",
+ org.apache.sshd.common.kex.extension.parser;version="[2.6.0,2.7.0)",
  org.apache.sshd.common.keyprovider;version="[2.6.0,2.7.0)",
  org.apache.sshd.common.mac;version="[2.6.0,2.7.0)",
  org.apache.sshd.common.random;version="[2.6.0,2.7.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 16b5738..5bc0867 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
@@ -24,7 +24,6 @@
 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}
 knownHostsFileLockedUpdate=Could not update known hosts file (locked) {0}
 knownHostsFileReadFailed=Failed to read known hosts file {0}
 knownHostsInvalidLine=Known hosts file {0} contains invalid line {1}
@@ -76,6 +75,9 @@
 proxySocksUnexpectedMessage=Unexpected message received from SOCKS5 proxy {0}; client state {1}: {2}
 proxySocksUnexpectedVersion=Expected SOCKS version 5, got {0}
 proxySocksUsernameTooLong=User name for proxy {0} must be at most 255 bytes long, is {1} bytes: {2}
+pubkeyAuthWrongCommand=Public key authentication received unknown SSH command {0} from {1} ({2})
+pubkeyAuthWrongKey=Public key authentication received wrong key; sent {0}, got back {1} from {2} ({3})
+pubkeyAuthWrongSignatureAlgorithm=Public key authentication requested signature type {0} but got back {1} from {2} ({3})
 serverIdNotReceived=No server identification received within {0} bytes
 serverIdTooLong=Server identification is longer than 255 characters (including line ending): {0}
 serverIdWithNul=Server identification contains a NUL character: {0}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitKexExtensionHandler.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitKexExtensionHandler.java
new file mode 100644
index 0000000..489c77d
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitKexExtensionHandler.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Distribution License v. 1.0 which is available at
+ * https://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+package org.eclipse.jgit.internal.transport.sshd;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.apache.sshd.common.AttributeRepository.AttributeKey;
+import org.apache.sshd.common.NamedFactory;
+import org.apache.sshd.common.kex.KexProposalOption;
+import org.apache.sshd.common.kex.extension.KexExtensionHandler;
+import org.apache.sshd.common.kex.extension.KexExtensions;
+import org.apache.sshd.common.kex.extension.parser.ServerSignatureAlgorithms;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.signature.Signature;
+import org.apache.sshd.common.util.logging.AbstractLoggingBean;
+import org.eclipse.jgit.util.StringUtils;
+
+/**
+ * Do not use the DefaultClientKexExtensionHandler from sshd; it doesn't work
+ * properly because of misconceptions. See SSHD-1141.
+ *
+ * @see <a href="https://issues.apache.org/jira/browse/SSHD-1141">SSHD-1141</a>
+ */
+public class JGitKexExtensionHandler extends AbstractLoggingBean
+		implements KexExtensionHandler {
+
+	/** Singleton instance. */
+	public static final JGitKexExtensionHandler INSTANCE = new JGitKexExtensionHandler();
+
+	/**
+	 * Session {@link AttributeKey} used to store whether the extension
+	 * indicator was already sent.
+	 */
+	private static final AttributeKey<Boolean> CLIENT_PROPOSAL_MADE = new AttributeKey<>();
+
+	/**
+	 * Session {@link AttributeKey} storing the algorithms announced by the
+	 * server as known.
+	 */
+	public static final AttributeKey<Set<String>> SERVER_ALGORITHMS = new AttributeKey<>();
+
+	private JGitKexExtensionHandler() {
+		// No public instantiation for singleton
+	}
+
+	@Override
+	public boolean isKexExtensionsAvailable(Session session,
+			AvailabilityPhase phase) throws IOException {
+		return !AvailabilityPhase.PREKEX.equals(phase);
+	}
+
+	@Override
+	public void handleKexInitProposal(Session session, boolean initiator,
+			Map<KexProposalOption, String> proposal) throws IOException {
+		// If it's the very first time, we may add the marker telling the server
+		// that we are ready to handle SSH_MSG_EXT_INFO
+		if (session == null || session.isServerSession() || !initiator) {
+			return;
+		}
+		if (session.getAttribute(CLIENT_PROPOSAL_MADE) != null) {
+			return;
+		}
+		String kexAlgorithms = proposal.get(KexProposalOption.SERVERKEYS);
+		if (StringUtils.isEmptyOrNull(kexAlgorithms)) {
+			return;
+		}
+		List<String> algorithms = new ArrayList<>();
+		// We're a client. We mustn't send the server extension, and we should
+		// send the client extension only once.
+		for (String algo : kexAlgorithms.split(",")) { //$NON-NLS-1$
+			if (KexExtensions.CLIENT_KEX_EXTENSION.equalsIgnoreCase(algo)
+					|| KexExtensions.SERVER_KEX_EXTENSION
+							.equalsIgnoreCase(algo)) {
+				continue;
+			}
+			algorithms.add(algo);
+		}
+		// Tell the server that we want to receive SSH2_MSG_EXT_INFO
+		algorithms.add(KexExtensions.CLIENT_KEX_EXTENSION);
+		if (log.isDebugEnabled()) {
+			log.debug(
+					"handleKexInitProposal({}): proposing HostKeyAlgorithms {}", //$NON-NLS-1$
+					session, algorithms);
+		}
+		proposal.put(KexProposalOption.SERVERKEYS,
+				String.join(",", algorithms)); //$NON-NLS-1$
+		session.setAttribute(CLIENT_PROPOSAL_MADE, Boolean.TRUE);
+	}
+
+	@Override
+	public boolean handleKexExtensionRequest(Session session, int index,
+			int count, String name, byte[] data) throws IOException {
+		if (ServerSignatureAlgorithms.NAME.equals(name)) {
+			handleServerSignatureAlgorithms(session,
+					ServerSignatureAlgorithms.INSTANCE.parseExtension(data));
+		}
+		return true;
+	}
+
+	/**
+	 * Perform updates after a server-sig-algs extension has been received.
+	 *
+	 * @param session
+	 *            the message was received for
+	 * @param serverAlgorithms
+	 *            signature algorithm names announced by the server
+	 */
+	protected void handleServerSignatureAlgorithms(Session session,
+			Collection<String> serverAlgorithms) {
+		if (log.isDebugEnabled()) {
+			log.debug("handleServerSignatureAlgorithms({}): {}", session, //$NON-NLS-1$
+					serverAlgorithms);
+		}
+		// Client determines order; server says what it supports. Re-order
+		// such that supported ones are at the front, in client order,
+		// followed by unsupported ones, also in client order.
+		if (serverAlgorithms != null && !serverAlgorithms.isEmpty()) {
+			List<NamedFactory<Signature>> clientAlgorithms = session
+					.getSignatureFactories();
+			if (log.isDebugEnabled()) {
+				log.debug(
+						"handleServerSignatureAlgorithms({}): PubkeyAcceptedAlgorithms before: {}", //$NON-NLS-1$
+						session, clientAlgorithms);
+			}
+			List<NamedFactory<Signature>> unknown = new ArrayList<>();
+			Set<String> known = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+			known.addAll(serverAlgorithms);
+			for (Iterator<NamedFactory<Signature>> iter = clientAlgorithms
+					.iterator(); iter.hasNext();) {
+				NamedFactory<Signature> algo = iter.next();
+				if (!known.contains(algo.getName())) {
+					unknown.add(algo);
+					iter.remove();
+				}
+			}
+			// Re-add the unknown ones at the end. Per RFC 8308, some
+			// servers may not announce _all_ their supported algorithms,
+			// and a client may use unknown algorithms.
+			clientAlgorithms.addAll(unknown);
+			if (log.isDebugEnabled()) {
+				log.debug(
+						"handleServerSignatureAlgorithms({}): PubkeyAcceptedAlgorithms after: {}", //$NON-NLS-1$
+						session, clientAlgorithms);
+			}
+			session.setAttribute(SERVER_ALGORITHMS, known);
+			session.setSignatureFactories(clientAlgorithms);
+		}
+	}
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java
index 297b456..6755094 100644
--- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java
@@ -11,6 +11,9 @@
 
 import java.io.IOException;
 import java.security.PublicKey;
+import java.security.spec.InvalidKeySpecException;
+import java.text.MessageFormat;
+import java.util.Deque;
 import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.List;
@@ -19,6 +22,7 @@
 import org.apache.sshd.client.auth.pubkey.UserAuthPublicKey;
 import org.apache.sshd.client.session.ClientSession;
 import org.apache.sshd.common.NamedFactory;
+import org.apache.sshd.common.NamedResource;
 import org.apache.sshd.common.RuntimeSshException;
 import org.apache.sshd.common.SshConstants;
 import org.apache.sshd.common.config.keys.KeyUtils;
@@ -37,7 +41,9 @@
  */
 public class JGitPublicKeyAuthentication extends UserAuthPublicKey {
 
-	private final List<String> algorithms = new LinkedList<>();
+	private final Deque<String> currentAlgorithms = new LinkedList<>();
+
+	private String chosenAlgorithm;
 
 	JGitPublicKeyAuthentication(List<NamedFactory<Signature>> factories) {
 		super(factories);
@@ -47,11 +53,25 @@ public class JGitPublicKeyAuthentication extends UserAuthPublicKey {
 	protected boolean sendAuthDataRequest(ClientSession session, String service)
 			throws Exception {
 		if (current == null) {
-			algorithms.clear();
+			currentAlgorithms.clear();
+			chosenAlgorithm = null;
 		}
 		String currentAlgorithm = null;
-		if (current != null && !algorithms.isEmpty()) {
-			currentAlgorithm = algorithms.remove(0);
+		if (current != null && !currentAlgorithms.isEmpty()) {
+			currentAlgorithm = currentAlgorithms.poll();
+			if (chosenAlgorithm != null) {
+				Set<String> knownServerAlgorithms = session.getAttribute(
+						JGitKexExtensionHandler.SERVER_ALGORITHMS);
+				if (knownServerAlgorithms != null
+						&& knownServerAlgorithms.contains(chosenAlgorithm)) {
+					// We've tried key 'current' with 'chosenAlgorithm', but it
+					// failed. However, the server had told us it supported
+					// 'chosenAlgorithm'. Thus it makes no sense to continue
+					// with this key and other signature algorithms. Skip to the
+					// next key, if any.
+					currentAlgorithm = null;
+				}
+			}
 		}
 		if (currentAlgorithm == null) {
 			try {
@@ -61,14 +81,13 @@ protected boolean sendAuthDataRequest(ClientSession session, String service)
 								"sendAuthDataRequest({})[{}] no more keys to send", //$NON-NLS-1$
 								session, service);
 					}
+					current = null;
 					return false;
 				}
 				current = keys.next();
-				algorithms.clear();
+				currentAlgorithms.clear();
+				chosenAlgorithm = null;
 			} catch (Error e) { // Copied from superclass
-				warn("sendAuthDataRequest({})[{}] failed ({}) to get next key: {}", //$NON-NLS-1$
-						session, service, e.getClass().getSimpleName(),
-						e.getMessage(), e);
 				throw new RuntimeSshException(e);
 			}
 		}
@@ -76,9 +95,6 @@ protected boolean sendAuthDataRequest(ClientSession session, String service)
 		try {
 			key = current.getPublicKey();
 		} catch (Error e) { // Copied from superclass
-			warn("sendAuthDataRequest({})[{}] failed ({}) to retrieve public key: {}", //$NON-NLS-1$
-					session, service, e.getClass().getSimpleName(),
-					e.getMessage(), e);
 			throw new RuntimeSshException(e);
 		}
 		if (currentAlgorithm == null) {
@@ -94,15 +110,21 @@ protected boolean sendAuthDataRequest(ClientSession session, String service)
 				existingFactories = getSignatureFactories();
 			}
 			if (existingFactories != null) {
+				if (log.isDebugEnabled()) {
+					log.debug(
+							"sendAuthDataRequest({})[{}] selecting from PubKeyAcceptedAlgorithms {}", //$NON-NLS-1$
+							session, service,
+							NamedResource.getNames(existingFactories));
+				}
 				// Select the factories by name and in order
 				existingFactories.forEach(f -> {
 					if (aliases.contains(f.getName())) {
-						algorithms.add(f.getName());
+						currentAlgorithms.add(f.getName());
 					}
 				});
 			}
-			currentAlgorithm = algorithms.isEmpty() ? keyType
-					: algorithms.remove(0);
+			currentAlgorithm = currentAlgorithms.isEmpty() ? keyType
+					: currentAlgorithms.poll();
 		}
 		String name = getName();
 		if (log.isDebugEnabled()) {
@@ -112,6 +134,7 @@ protected boolean sendAuthDataRequest(ClientSession session, String service)
 					KeyUtils.getFingerPrint(key));
 		}
 
+		chosenAlgorithm = currentAlgorithm;
 		Buffer buffer = session
 				.createBuffer(SshConstants.SSH_MSG_USERAUTH_REQUEST);
 		buffer.putString(session.getUsername());
@@ -125,9 +148,77 @@ protected boolean sendAuthDataRequest(ClientSession session, String service)
 	}
 
 	@Override
+	protected boolean processAuthDataRequest(ClientSession session,
+			String service, Buffer buffer) throws Exception {
+		String name = getName();
+		int cmd = buffer.getUByte();
+		if (cmd != SshConstants.SSH_MSG_USERAUTH_PK_OK) {
+			throw new IllegalStateException(MessageFormat.format(
+					SshdText.get().pubkeyAuthWrongCommand,
+					SshConstants.getCommandMessageName(cmd),
+					session.getConnectAddress(), session.getServerVersion()));
+		}
+		PublicKey key;
+		try {
+			key = current.getPublicKey();
+		} catch (Error e) { // Copied from superclass
+			throw new RuntimeSshException(e);
+		}
+		String rspKeyAlgorithm = buffer.getString();
+		PublicKey rspKey = buffer.getPublicKey();
+		if (log.isDebugEnabled()) {
+			log.debug(
+					"processAuthDataRequest({})[{}][{}] SSH_MSG_USERAUTH_PK_OK type={}, fingerprint={}", //$NON-NLS-1$
+					session, service, name, rspKeyAlgorithm,
+					KeyUtils.getFingerPrint(rspKey));
+		}
+		if (!KeyUtils.compareKeys(rspKey, key)) {
+			throw new InvalidKeySpecException(MessageFormat.format(
+					SshdText.get().pubkeyAuthWrongKey,
+					KeyUtils.getFingerPrint(key),
+					KeyUtils.getFingerPrint(rspKey),
+					session.getConnectAddress(), session.getServerVersion()));
+		}
+		if (!chosenAlgorithm.equalsIgnoreCase(rspKeyAlgorithm)) {
+			// 'algo' SHOULD be the same as 'chosenAlgorithm', which is the one
+			// we sent above. See https://tools.ietf.org/html/rfc4252#page-9 .
+			//
+			// However, at least Github (SSH-2.0-babeld-383743ad) servers seem
+			// to return the key type, not the algorithm name.
+			//
+			// So we don't check but just log the inconsistency. We sign using
+			// 'chosenAlgorithm' in any case, so we don't really care what the
+			// server says here.
+			log.warn(MessageFormat.format(
+					SshdText.get().pubkeyAuthWrongSignatureAlgorithm,
+					chosenAlgorithm, rspKeyAlgorithm, session.getConnectAddress(),
+					session.getServerVersion()));
+		}
+		String username = session.getUsername();
+		Buffer out = session
+				.createBuffer(SshConstants.SSH_MSG_USERAUTH_REQUEST);
+		out.putString(username);
+		out.putString(service);
+		out.putString(name);
+		out.putBoolean(true);
+		out.putString(chosenAlgorithm);
+		out.putPublicKey(key);
+		if (log.isDebugEnabled()) {
+			log.debug(
+					"processAuthDataRequest({})[{}][{}]: signing with algorithm {}", //$NON-NLS-1$
+					session, service, name, chosenAlgorithm);
+		}
+		appendSignature(session, service, name, username, chosenAlgorithm, key,
+				out);
+		session.writePacket(out);
+		return true;
+	}
+
+	@Override
 	protected void releaseKeys() throws IOException {
-		algorithms.clear();
+		currentAlgorithms.clear();
 		current = null;
+		chosenAlgorithm = null;
 		super.releaseKeys();
 	}
 }
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/OpenSshServerKeyDatabase.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/OpenSshServerKeyDatabase.java
index 47e09b7..1a530b7 100644
--- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/OpenSshServerKeyDatabase.java
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/OpenSshServerKeyDatabase.java
@@ -21,6 +21,7 @@
 import java.net.SocketAddress;
 import java.nio.file.Files;
 import java.nio.file.InvalidPathException;
+import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.security.GeneralSecurityException;
@@ -561,29 +562,17 @@ public HostKeyFile(Path path) {
 		@Override
 		public List<HostEntryPair> get() {
 			Path path = getPath();
-			try {
-				if (checkReloadRequired()) {
-					if (!Files.exists(path)) {
-						// Has disappeared.
-						resetReloadAttributes();
-						return Collections.emptyList();
+			synchronized (this) {
+				try {
+					if (checkReloadRequired()) {
+						entries = reload(getPath());
 					}
-					LockFile lock = new LockFile(path.toFile());
-					if (lock.lock()) {
-						try {
-							entries = reload(getPath());
-						} finally {
-							lock.unlock();
-						}
-					} else {
-						LOG.warn(format(SshdText.get().knownHostsFileLockedRead,
-								path));
-					}
+				} catch (IOException e) {
+					LOG.warn(format(SshdText.get().knownHostsFileReadFailed,
+							path));
 				}
-			} catch (IOException e) {
-				LOG.warn(format(SshdText.get().knownHostsFileReadFailed, path));
+				return Collections.unmodifiableList(entries);
 			}
-			return Collections.unmodifiableList(entries);
 		}
 
 		private List<HostEntryPair> reload(Path path) throws IOException {
@@ -616,7 +605,7 @@ private List<HostEntryPair> reload(Path path) throws IOException {
 					}
 				}
 				return newEntries;
-			} catch (FileNotFoundException e) {
+			} catch (FileNotFoundException | NoSuchFileException e) {
 				resetReloadAttributes();
 				return Collections.emptyList();
 			}
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 4c4ff59..73c2288 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
@@ -44,7 +44,6 @@ public static SshdText get() {
 	/***/ public String keyEncryptedRetry;
 	/***/ public String keyLoadFailed;
 	/***/ public String knownHostsCouldNotUpdate;
-	/***/ public String knownHostsFileLockedRead;
 	/***/ public String knownHostsFileLockedUpdate;
 	/***/ public String knownHostsFileReadFailed;
 	/***/ public String knownHostsInvalidLine;
@@ -88,6 +87,9 @@ public static SshdText get() {
 	/***/ public String proxySocksUnexpectedMessage;
 	/***/ public String proxySocksUnexpectedVersion;
 	/***/ public String proxySocksUsernameTooLong;
+	/***/ public String pubkeyAuthWrongCommand;
+	/***/ public String pubkeyAuthWrongKey;
+	/***/ public String pubkeyAuthWrongSignatureAlgorithm;
 	/***/ public String serverIdNotReceived;
 	/***/ public String serverIdTooLong;
 	/***/ public String serverIdWithNul;
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java
index cad959c..2d7e0c7 100644
--- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java
@@ -47,6 +47,7 @@
 import org.eclipse.jgit.internal.transport.sshd.AuthenticationCanceledException;
 import org.eclipse.jgit.internal.transport.sshd.CachingKeyPairProvider;
 import org.eclipse.jgit.internal.transport.sshd.GssApiWithMicAuthFactory;
+import org.eclipse.jgit.internal.transport.sshd.JGitKexExtensionHandler;
 import org.eclipse.jgit.internal.transport.sshd.JGitPasswordAuthFactory;
 import org.eclipse.jgit.internal.transport.sshd.JGitPublicKeyAuthFactory;
 import org.eclipse.jgit.internal.transport.sshd.JGitServerKeyVerifier;
@@ -216,6 +217,7 @@ public SshdSession getSession(URIish uri,
 						new JGitUserInteraction(credentialsProvider));
 				client.setUserAuthFactories(getUserAuthFactories());
 				client.setKeyIdentityProvider(defaultKeysProvider);
+				client.setKexExtensionHandler(JGitKexExtensionHandler.INSTANCE);
 				// JGit-specific things:
 				JGitSshClient jgitClient = (JGitSshClient) client;
 				jgitClient.setKeyCache(getKeyCache());
diff --git a/org.eclipse.jgit.ssh.jsch.test/tst/org/eclipse/jgit/transport/OpenSshConfigTest.java b/org.eclipse.jgit.ssh.jsch.test/tst/org/eclipse/jgit/transport/OpenSshConfigTest.java
index 4c7e99e..4be2271 100644
--- a/org.eclipse.jgit.ssh.jsch.test/tst/org/eclipse/jgit/transport/OpenSshConfigTest.java
+++ b/org.eclipse.jgit.ssh.jsch.test/tst/org/eclipse/jgit/transport/OpenSshConfigTest.java
@@ -58,6 +58,7 @@ public void setUp() throws Exception {
 		FileUtils.mkdir(configFile.getParentFile());
 
 		mockSystemReader.setProperty(Constants.OS_USER_NAME_KEY, "jex_junit");
+		mockSystemReader.setProperty("TST_VAR", "TEST");
 		osc = new OpenSshConfig(home, configFile);
 	}
 
@@ -497,4 +498,23 @@ public void testEolComments() throws Exception {
 		assertEquals("^ssh-rsa",
 				c.getValue(SshConstants.PUBKEY_ACCEPTED_ALGORITHMS));
 	}
+
+	@Test
+	public void testEnVarSubstitution() throws Exception {
+		config("Host orcz\nIdentityFile /tmp/${TST_VAR}\n"
+				+ "CertificateFile /tmp/${}/foo\nUser ${TST_VAR}\nIdentityAgent /tmp/${TST_VAR/bar");
+		Host h = osc.lookup("orcz");
+		assertNotNull(h);
+		Config c = h.getConfig();
+		assertEquals("/tmp/TEST",
+				c.getValue(SshConstants.IDENTITY_FILE));
+		// No variable name
+		assertEquals("/tmp/${}/foo", c.getValue(SshConstants.CERTIFICATE_FILE));
+		// User doesn't get env var substitution:
+		assertEquals("${TST_VAR}", c.getValue(SshConstants.USER));
+		assertEquals("${TST_VAR}", h.getUser());
+		// Unterminated:
+		assertEquals("/tmp/${TST_VAR/bar",
+				c.getValue(SshConstants.IDENTITY_AGENT));
+	}
 }
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/merge/MergerTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/merge/MergerTest.java
index eecf25b..6cbb4a8 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/merge/MergerTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/merge/MergerTest.java
@@ -1648,6 +1648,82 @@ public void checkFileDirMergeConflictInVirtualAncestor_ConflictInChildren_DirFil
 				indexState(CONTENT));
 	}
 
+	/**
+	 * Merging two commits when files have equal content, but conflicting file mode
+	 * in the virtual ancestor.
+	 *
+	 * <p>
+	 * This test has the same set up as
+	 * {@code checkFileDirMergeConflictInVirtualAncestor_NoConflictInChildren}, only
+	 * with the mode conflict in A1 and A2.
+	 */
+	@Theory
+	public void checkModeMergeConflictInVirtualAncestor(MergeStrategy strategy) throws Exception {
+		if (!strategy.equals(MergeStrategy.RECURSIVE)) {
+			return;
+		}
+
+		Git git = Git.wrap(db);
+
+		// master
+		writeTrashFile("c", "initial file");
+		git.add().addFilepattern("c").call();
+		RevCommit commitI = git.commit().setMessage("Initial commit").call();
+
+		File a = writeTrashFile("a", "content in Ancestor");
+		git.add().addFilepattern("a").call();
+		RevCommit commitA1 = git.commit().setMessage("Ancestor 1").call();
+
+		a = writeTrashFile("a", "content in Child 1 (commited on master)");
+		git.add().addFilepattern("a").call();
+		// commit C1M
+		git.commit().setMessage("Child 1 on master").call();
+
+		git.checkout().setCreateBranch(true).setStartPoint(commitI).setName("branch-to-merge").call();
+		// "a" becomes executable in A2
+		a = writeTrashFile("a", "content in Ancestor");
+		a.setExecutable(true);
+		git.add().addFilepattern("a").call();
+		RevCommit commitA2 = git.commit().setMessage("Ancestor 2").call();
+
+		// second branch
+		git.checkout().setCreateBranch(true).setStartPoint(commitA1).setName("second-branch").call();
+		a = writeTrashFile("a", "content in Child 2 (commited on second-branch)");
+		git.add().addFilepattern("a").call();
+		// commit C2S
+		git.commit().setMessage("Child 2 on second-branch").call();
+
+		// Merge branch-to-merge into second-branch
+		MergeResult mergeResult = git.merge().include(commitA2).setStrategy(strategy).call();
+		assertEquals(mergeResult.getNewHead(), null);
+		assertEquals(mergeResult.getMergeStatus(), MergeStatus.CONFLICTING);
+		// Resolve the conflict manually, merge "a" as non-executable
+		a = writeTrashFile("a", "merge conflict resolution");
+		a.setExecutable(false);
+		git.add().addFilepattern("a").call();
+		RevCommit commitC3S = git.commit().setMessage("Child 3 on second bug - resolve merge conflict").call();
+
+		// Merge branch-to-merge into master
+		git.checkout().setName("master").call();
+		mergeResult = git.merge().include(commitA2).setStrategy(strategy).call();
+		assertEquals(mergeResult.getNewHead(), null);
+		assertEquals(mergeResult.getMergeStatus(), MergeStatus.CONFLICTING);
+
+		// Resolve the conflict manually - merge "a" as non-executable
+		a = writeTrashFile("a", "merge conflict resolution");
+		a.setExecutable(false);
+		git.add().addFilepattern("a").call();
+		// commit C4M
+		git.commit().setMessage("Child 4 on master - resolve merge conflict").call();
+
+		// Merge C4M (second-branch) into master (C3S)
+		// Conflict in virtual base should be here, but there are no conflicts in
+		// children
+		mergeResult = git.merge().include(commitC3S).call();
+		assertEquals(mergeResult.getMergeStatus(), MergeStatus.MERGED);
+
+	}
+
 	private void writeSubmodule(String path, ObjectId commit)
 			throws IOException, ConfigInvalidException {
 		addSubmoduleToIndex(path, commit);
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/transport/ssh/OpenSshConfigFile.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/transport/ssh/OpenSshConfigFile.java
index c514270..de6a346 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/internal/transport/ssh/OpenSshConfigFile.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/transport/ssh/OpenSshConfigFile.java
@@ -708,10 +708,10 @@ void merge(HostEntry entry) {
 		}
 
 		private List<String> substitute(List<String> values, String allowed,
-				Replacer r) {
+				Replacer r, boolean withEnv) {
 			List<String> result = new ArrayList<>(values.size());
 			for (String value : values) {
-				result.add(r.substitute(value, allowed));
+				result.add(r.substitute(value, allowed, withEnv));
 			}
 			return result;
 		}
@@ -743,7 +743,7 @@ void substitute(String originalHostName, int port, String userName,
 				if (hostName == null || hostName.isEmpty()) {
 					options.put(SshConstants.HOST_NAME, originalHostName);
 				} else {
-					hostName = r.substitute(hostName, "h"); //$NON-NLS-1$
+					hostName = r.substitute(hostName, "h", false); //$NON-NLS-1$
 					options.put(SshConstants.HOST_NAME, hostName);
 					r.update('h', hostName);
 				}
@@ -752,13 +752,13 @@ void substitute(String originalHostName, int port, String userName,
 				List<String> values = multiOptions
 						.get(SshConstants.IDENTITY_FILE);
 				if (values != null) {
-					values = substitute(values, "dhlru", r); //$NON-NLS-1$
+					values = substitute(values, "dhlru", r, true); //$NON-NLS-1$
 					values = replaceTilde(values, home);
 					multiOptions.put(SshConstants.IDENTITY_FILE, values);
 				}
 				values = multiOptions.get(SshConstants.CERTIFICATE_FILE);
 				if (values != null) {
-					values = substitute(values, "dhlru", r); //$NON-NLS-1$
+					values = substitute(values, "dhlru", r, true); //$NON-NLS-1$
 					values = replaceTilde(values, home);
 					multiOptions.put(SshConstants.CERTIFICATE_FILE, values);
 				}
@@ -775,29 +775,29 @@ void substitute(String originalHostName, int port, String userName,
 				// HOSTNAME already done above
 				String value = options.get(SshConstants.IDENTITY_AGENT);
 				if (value != null) {
-					value = r.substitute(value, "dhlru"); //$NON-NLS-1$
+					value = r.substitute(value, "dhlru", true); //$NON-NLS-1$
 					value = toFile(value, home).getPath();
 					options.put(SshConstants.IDENTITY_AGENT, value);
 				}
 				value = options.get(SshConstants.CONTROL_PATH);
 				if (value != null) {
-					value = r.substitute(value, "ChLlnpru"); //$NON-NLS-1$
+					value = r.substitute(value, "ChLlnpru", true); //$NON-NLS-1$
 					value = toFile(value, home).getPath();
 					options.put(SshConstants.CONTROL_PATH, value);
 				}
 				value = options.get(SshConstants.LOCAL_COMMAND);
 				if (value != null) {
-					value = r.substitute(value, "CdhlnprTu"); //$NON-NLS-1$
+					value = r.substitute(value, "CdhlnprTu", false); //$NON-NLS-1$
 					options.put(SshConstants.LOCAL_COMMAND, value);
 				}
 				value = options.get(SshConstants.REMOTE_COMMAND);
 				if (value != null) {
-					value = r.substitute(value, "Cdhlnpru"); //$NON-NLS-1$
+					value = r.substitute(value, "Cdhlnpru", false); //$NON-NLS-1$
 					options.put(SshConstants.REMOTE_COMMAND, value);
 				}
 				value = options.get(SshConstants.PROXY_COMMAND);
 				if (value != null) {
-					value = r.substitute(value, "hpr"); //$NON-NLS-1$
+					value = r.substitute(value, "hpr", false); //$NON-NLS-1$
 					options.put(SshConstants.PROXY_COMMAND, value);
 				}
 			}
@@ -871,7 +871,7 @@ public Replacer(String host, int port, String user,
 			replacements.put(Character.valueOf('r'), user == null ? "" : user); //$NON-NLS-1$
 			replacements.put(Character.valueOf('u'), localUserName);
 			replacements.put(Character.valueOf('C'),
-					substitute("%l%h%p%r", "hlpr")); //$NON-NLS-1$ //$NON-NLS-2$
+					substitute("%l%h%p%r", "hlpr", false)); //$NON-NLS-1$ //$NON-NLS-2$
 			replacements.put(Character.valueOf('T'), "NONE"); //$NON-NLS-1$
 		}
 
@@ -879,36 +879,63 @@ public void update(char key, String value) {
 			replacements.put(Character.valueOf(key), value);
 			if ("lhpr".indexOf(key) >= 0) { //$NON-NLS-1$
 				replacements.put(Character.valueOf('C'),
-						substitute("%l%h%p%r", "hlpr")); //$NON-NLS-1$ //$NON-NLS-2$
+						substitute("%l%h%p%r", "hlpr", false)); //$NON-NLS-1$ //$NON-NLS-2$
 			}
 		}
 
-		public String substitute(String input, String allowed) {
+		public String substitute(String input, String allowed,
+				boolean withEnv) {
 			if (input == null || input.length() <= 1
-					|| input.indexOf('%') < 0) {
+					|| input.indexOf('%') < 0
+							&& (!withEnv || input.indexOf("${") < 0)) { //$NON-NLS-1$
 				return input;
 			}
 			StringBuilder builder = new StringBuilder();
 			int start = 0;
 			int length = input.length();
 			while (start < length) {
-				int percent = input.indexOf('%', start);
-				if (percent < 0 || percent + 1 >= length) {
-					builder.append(input.substring(start));
+				char ch = input.charAt(start);
+				switch (ch) {
+				case '%':
+					if (start + 1 >= length) {
+						break;
+					}
+					String replacement = null;
+					ch = input.charAt(start + 1);
+					if (ch == '%' || allowed.indexOf(ch) >= 0) {
+						replacement = replacements.get(Character.valueOf(ch));
+					}
+					if (replacement == null) {
+						builder.append('%').append(ch);
+					} else {
+						builder.append(replacement);
+					}
+					start += 2;
+					continue;
+				case '$':
+					if (!withEnv || start + 2 >= length) {
+						break;
+					}
+					ch = input.charAt(start + 1);
+					if (ch == '{') {
+						int close = input.indexOf('}', start + 2);
+						if (close > start + 2) {
+							String variable = SystemReader.getInstance()
+									.getenv(input.substring(start + 2, close));
+							if (!StringUtils.isEmptyOrNull(variable)) {
+								builder.append(variable);
+							}
+							start = close + 1;
+							continue;
+						}
+					}
+					ch = '$';
+					break;
+				default:
 					break;
 				}
-				String replacement = null;
-				char ch = input.charAt(percent + 1);
-				if (ch == '%' || allowed.indexOf(ch) >= 0) {
-					replacement = replacements.get(Character.valueOf(ch));
-				}
-				if (replacement == null) {
-					builder.append(input.substring(start, percent + 2));
-				} else {
-					builder.append(input.substring(start, percent))
-							.append(replacement);
-				}
-				start = percent + 2;
+				builder.append(ch);
+				start++;
 			}
 			return builder.toString();
 		}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java b/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java
index 4bfb38d..b011258 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java
@@ -644,15 +644,18 @@ protected boolean processEntry(CanonicalTreeParser base,
 				}
 				return true;
 			}
-			// FileModes are not mergeable. We found a conflict on modes.
-			// For conflicting entries we don't know lastModified and
-			// length.
-			add(tw.getRawPath(), base, DirCacheEntry.STAGE_1, EPOCH, 0);
-			add(tw.getRawPath(), ours, DirCacheEntry.STAGE_2, EPOCH, 0);
-			add(tw.getRawPath(), theirs, DirCacheEntry.STAGE_3, EPOCH, 0);
-			unmergedPaths.add(tw.getPathString());
-			mergeResults.put(tw.getPathString(),
-					new MergeResult<>(Collections.<RawText> emptyList()));
+			if (!ignoreConflicts) {
+				// FileModes are not mergeable. We found a conflict on modes.
+				// For conflicting entries we don't know lastModified and
+				// length.
+				// This path can be skipped on ignoreConflicts, so the caller
+				// could use virtual commit.
+				add(tw.getRawPath(), base, DirCacheEntry.STAGE_1, EPOCH, 0);
+				add(tw.getRawPath(), ours, DirCacheEntry.STAGE_2, EPOCH, 0);
+				add(tw.getRawPath(), theirs, DirCacheEntry.STAGE_3, EPOCH, 0);
+				unmergedPaths.add(tw.getPathString());
+				mergeResults.put(tw.getPathString(), new MergeResult<>(Collections.<RawText>emptyList()));
+			}
 			return true;
 		}
 
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/UsernamePasswordCredentialsProvider.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/UsernamePasswordCredentialsProvider.java
index 979961f..c0de42c 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/transport/UsernamePasswordCredentialsProvider.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/UsernamePasswordCredentialsProvider.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2010, Google Inc. and others
+ * Copyright (C) 2010, 2021 Google Inc. and others
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Distribution License v. 1.0 which is available at
@@ -58,14 +58,21 @@ public boolean isInteractive() {
 	@Override
 	public boolean supports(CredentialItem... items) {
 		for (CredentialItem i : items) {
-			if (i instanceof CredentialItem.Username)
+			if (i instanceof CredentialItem.InformationalMessage) {
 				continue;
-
-			else if (i instanceof CredentialItem.Password)
+			}
+			if (i instanceof CredentialItem.Username) {
 				continue;
-
-			else
-				return false;
+			}
+			if (i instanceof CredentialItem.Password) {
+				continue;
+			}
+			if (i instanceof CredentialItem.StringType) {
+				if (i.getPromptText().equals("Password: ")) { //$NON-NLS-1$
+					continue;
+				}
+			}
+			return false;
 		}
 		return true;
 	}
@@ -75,6 +82,9 @@ else if (i instanceof CredentialItem.Password)
 	public boolean get(URIish uri, CredentialItem... items)
 			throws UnsupportedCredentialItem {
 		for (CredentialItem i : items) {
+			if (i instanceof CredentialItem.InformationalMessage) {
+				continue;
+			}
 			if (i instanceof CredentialItem.Username) {
 				((CredentialItem.Username) i).setValue(username);
 				continue;