JschConfigSessionFactory.java
/*
* Copyright (C) 2018, Sasa Zivkov <sasa.zivkov@sap.com>
* Copyright (C) 2016, Mark Ingram <markdingram@gmail.com>
* Copyright (C) 2009, Constantine Plotnikov <constantine.plotnikov@gmail.com>
* Copyright (C) 2008-2009, Google Inc.
* Copyright (C) 2009, Google, Inc.
* Copyright (C) 2009, JetBrains s.r.o.
* Copyright (C) 2008, Robin Rosenberg <robin.rosenberg@dewire.com>
* Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org>
* 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;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.ConnectException;
import java.net.UnknownHostException;
import java.text.MessageFormat;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import org.eclipse.jgit.errors.TransportException;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.util.FS;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jcraft.jsch.ConfigRepository;
import com.jcraft.jsch.ConfigRepository.Config;
import com.jcraft.jsch.HostKey;
import com.jcraft.jsch.HostKeyRepository;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
/**
* The base session factory that loads known hosts and private keys from
* <code>$HOME/.ssh</code>.
* <p>
* This is the default implementation used by JGit and provides most of the
* compatibility necessary to match OpenSSH, a popular implementation of SSH
* used by C Git.
* <p>
* The factory does not provide UI behavior. Override the method
* {@link #configure(org.eclipse.jgit.transport.OpenSshConfig.Host, Session)} to
* supply appropriate {@link com.jcraft.jsch.UserInfo} to the session.
*/
public abstract class JschConfigSessionFactory extends SshSessionFactory {
private static final Logger LOG = LoggerFactory
.getLogger(JschConfigSessionFactory.class);
/**
* We use different Jsch instances for hosts that have an IdentityFile
* configured in ~/.ssh/config. Jsch by default would cache decrypted keys
* only per session, which results in repeated password prompts. Using
* different Jsch instances, we can cache the keys on these instances so
* that they will be re-used for successive sessions, and thus the user is
* prompted for a key password only once while Eclipse runs.
*/
private final Map<String, JSch> byIdentityFile = new HashMap<>();
private JSch defaultJSch;
private OpenSshConfig config;
/** {@inheritDoc} */
@Override
public synchronized RemoteSession getSession(URIish uri,
CredentialsProvider credentialsProvider, FS fs, int tms)
throws TransportException {
String user = uri.getUser();
final String pass = uri.getPass();
String host = uri.getHost();
int port = uri.getPort();
try {
if (config == null)
config = OpenSshConfig.get(fs);
final OpenSshConfig.Host hc = config.lookup(host);
if (port <= 0)
port = hc.getPort();
if (user == null)
user = hc.getUser();
Session session = createSession(credentialsProvider, fs, user,
pass, host, port, hc);
int retries = 0;
while (!session.isConnected()) {
try {
retries++;
session.connect(tms);
} catch (JSchException e) {
session.disconnect();
session = null;
// Make sure our known_hosts is not outdated
knownHosts(getJSch(hc, fs), fs);
if (isAuthenticationCanceled(e)) {
throw e;
} else if (isAuthenticationFailed(e)
&& credentialsProvider != null) {
// if authentication failed maybe credentials changed at
// the remote end therefore reset credentials and retry
if (retries < 3) {
credentialsProvider.reset(uri);
session = createSession(credentialsProvider, fs,
user, pass, host, port, hc);
} else
throw e;
} else if (retries >= hc.getConnectionAttempts()) {
throw e;
} else {
try {
Thread.sleep(1000);
session = createSession(credentialsProvider, fs,
user, pass, host, port, hc);
} catch (InterruptedException e1) {
throw new TransportException(
JGitText.get().transportSSHRetryInterrupt,
e1);
}
}
}
}
return new JschSession(session, uri);
} catch (JSchException je) {
final Throwable c = je.getCause();
if (c instanceof UnknownHostException) {
throw new TransportException(uri, JGitText.get().unknownHost,
je);
}
if (c instanceof ConnectException) {
throw new TransportException(uri, c.getMessage(), je);
}
throw new TransportException(uri, je.getMessage(), je);
}
}
private static boolean isAuthenticationFailed(JSchException e) {
return e.getCause() == null && e.getMessage().equals("Auth fail"); //$NON-NLS-1$
}
private static boolean isAuthenticationCanceled(JSchException e) {
return e.getCause() == null && e.getMessage().equals("Auth cancel"); //$NON-NLS-1$
}
// Package visibility for tests
Session createSession(CredentialsProvider credentialsProvider,
FS fs, String user, final String pass, String host, int port,
final OpenSshConfig.Host hc) throws JSchException {
final Session session = createSession(hc, user, host, port, fs);
// Jsch will have overridden the explicit user by the one from the SSH
// config file...
setUserName(session, user);
// Jsch will also have overridden the port.
if (port > 0 && port != session.getPort()) {
session.setPort(port);
}
// We retry already in getSession() method. JSch must not retry
// on its own.
session.setConfig("MaxAuthTries", "1"); //$NON-NLS-1$ //$NON-NLS-2$
if (pass != null)
session.setPassword(pass);
final String strictHostKeyCheckingPolicy = hc
.getStrictHostKeyChecking();
if (strictHostKeyCheckingPolicy != null)
session.setConfig("StrictHostKeyChecking", //$NON-NLS-1$
strictHostKeyCheckingPolicy);
final String pauth = hc.getPreferredAuthentications();
if (pauth != null)
session.setConfig("PreferredAuthentications", pauth); //$NON-NLS-1$
if (credentialsProvider != null
&& (!hc.isBatchMode() || !credentialsProvider.isInteractive())) {
session.setUserInfo(new CredentialsProviderUserInfo(session,
credentialsProvider));
}
safeConfig(session, hc.getConfig());
if (hc.getConfig().getValue("HostKeyAlgorithms") == null) { //$NON-NLS-1$
setPreferredKeyTypesOrder(session);
}
configure(hc, session);
return session;
}
private void safeConfig(Session session, Config cfg) {
// Ensure that Jsch checks all configured algorithms, not just its
// built-in ones. Otherwise it may propose an algorithm for which it
// doesn't have an implementation, and then run into an NPE if that
// algorithm ends up being chosen.
copyConfigValueToSession(session, cfg, "Ciphers", "CheckCiphers"); //$NON-NLS-1$ //$NON-NLS-2$
copyConfigValueToSession(session, cfg, "KexAlgorithms", "CheckKexes"); //$NON-NLS-1$ //$NON-NLS-2$
copyConfigValueToSession(session, cfg, "HostKeyAlgorithms", //$NON-NLS-1$
"CheckSignatures"); //$NON-NLS-1$
}
private static void setPreferredKeyTypesOrder(Session session) {
HostKeyRepository hkr = session.getHostKeyRepository();
List<String> known = Stream.of(hkr.getHostKey(hostName(session), null))
.map(HostKey::getType)
.collect(toList());
if (!known.isEmpty()) {
String serverHostKey = "server_host_key"; //$NON-NLS-1$
String current = session.getConfig(serverHostKey);
if (current == null) {
session.setConfig(serverHostKey, String.join(",", known)); //$NON-NLS-1$
return;
}
String knownFirst = Stream.concat(
known.stream(),
Stream.of(current.split(",")) //$NON-NLS-1$
.filter(s -> !known.contains(s)))
.collect(joining(",")); //$NON-NLS-1$
session.setConfig(serverHostKey, knownFirst);
}
}
private static String hostName(Session s) {
if (s.getPort() == SshConstants.SSH_DEFAULT_PORT) {
return s.getHost();
}
return String.format("[%s]:%d", s.getHost(), //$NON-NLS-1$
Integer.valueOf(s.getPort()));
}
private void copyConfigValueToSession(Session session, Config cfg,
String from, String to) {
String value = cfg.getValue(from);
if (value != null) {
session.setConfig(to, value);
}
}
private void setUserName(Session session, String userName) {
// Jsch 0.1.54 picks up the user name from the ssh config, even if an
// explicit user name was given! We must correct that if ~/.ssh/config
// has a different user name.
if (userName == null || userName.isEmpty()
|| userName.equals(session.getUserName())) {
return;
}
try {
Class<?>[] parameterTypes = { String.class };
Method method = Session.class.getDeclaredMethod("setUserName", //$NON-NLS-1$
parameterTypes);
method.setAccessible(true);
method.invoke(session, userName);
} catch (NullPointerException | IllegalAccessException
| IllegalArgumentException | InvocationTargetException
| NoSuchMethodException | SecurityException e) {
LOG.error(MessageFormat.format(JGitText.get().sshUserNameError,
userName, session.getUserName()), e);
}
}
/**
* Create a new remote session for the requested address.
*
* @param hc
* host configuration
* @param user
* login to authenticate as.
* @param host
* server name to connect to.
* @param port
* port number of the SSH daemon (typically 22).
* @param fs
* the file system abstraction which will be necessary to
* perform certain file system operations.
* @return new session instance, but otherwise unconfigured.
* @throws com.jcraft.jsch.JSchException
* the session could not be created.
*/
protected Session createSession(final OpenSshConfig.Host hc,
final String user, final String host, final int port, FS fs)
throws JSchException {
return getJSch(hc, fs).getSession(user, host, port);
}
/**
* Provide additional configuration for the JSch instance. This method could
* be overridden to supply a preferred
* {@link com.jcraft.jsch.IdentityRepository}.
*
* @param jsch
* jsch instance
* @since 4.5
*/
protected void configureJSch(JSch jsch) {
// No additional configuration required.
}
/**
* Provide additional configuration for the session based on the host
* information. This method could be used to supply
* {@link com.jcraft.jsch.UserInfo}.
*
* @param hc
* host configuration
* @param session
* session to configure
*/
protected abstract void configure(OpenSshConfig.Host hc, Session session);
/**
* Obtain the JSch used to create new sessions.
*
* @param hc
* host configuration
* @param fs
* the file system abstraction which will be necessary to
* perform certain file system operations.
* @return the JSch instance to use.
* @throws com.jcraft.jsch.JSchException
* the user configuration could not be created.
*/
protected JSch getJSch(OpenSshConfig.Host hc, FS fs) throws JSchException {
if (defaultJSch == null) {
defaultJSch = createDefaultJSch(fs);
if (defaultJSch.getConfigRepository() == null) {
defaultJSch.setConfigRepository(
new JschBugFixingConfigRepository(config));
}
for (Object name : defaultJSch.getIdentityNames())
byIdentityFile.put((String) name, defaultJSch);
}
final File identityFile = hc.getIdentityFile();
if (identityFile == null)
return defaultJSch;
final String identityKey = identityFile.getAbsolutePath();
JSch jsch = byIdentityFile.get(identityKey);
if (jsch == null) {
jsch = new JSch();
configureJSch(jsch);
if (jsch.getConfigRepository() == null) {
jsch.setConfigRepository(defaultJSch.getConfigRepository());
}
jsch.setHostKeyRepository(defaultJSch.getHostKeyRepository());
jsch.addIdentity(identityKey);
byIdentityFile.put(identityKey, jsch);
}
return jsch;
}
/**
* Create default instance of jsch
*
* @param fs
* the file system abstraction which will be necessary to perform
* certain file system operations.
* @return the new default JSch implementation.
* @throws com.jcraft.jsch.JSchException
* known host keys cannot be loaded.
*/
protected JSch createDefaultJSch(FS fs) throws JSchException {
final JSch jsch = new JSch();
JSch.setConfig("ssh-rsa", JSch.getConfig("signature.rsa")); //$NON-NLS-1$ //$NON-NLS-2$
JSch.setConfig("ssh-dss", JSch.getConfig("signature.dss")); //$NON-NLS-1$ //$NON-NLS-2$
configureJSch(jsch);
knownHosts(jsch, fs);
identities(jsch, fs);
return jsch;
}
private static void knownHosts(JSch sch, FS fs) throws JSchException {
final File home = fs.userHome();
if (home == null)
return;
final File known_hosts = new File(new File(home, ".ssh"), "known_hosts"); //$NON-NLS-1$ //$NON-NLS-2$
try (FileInputStream in = new FileInputStream(known_hosts)) {
sch.setKnownHosts(in);
} catch (FileNotFoundException none) {
// Oh well. They don't have a known hosts in home.
} catch (IOException err) {
// Oh well. They don't have a known hosts in home.
}
}
private static void identities(JSch sch, FS fs) {
final File home = fs.userHome();
if (home == null)
return;
final File sshdir = new File(home, ".ssh"); //$NON-NLS-1$
if (sshdir.isDirectory()) {
loadIdentity(sch, new File(sshdir, "identity")); //$NON-NLS-1$
loadIdentity(sch, new File(sshdir, "id_rsa")); //$NON-NLS-1$
loadIdentity(sch, new File(sshdir, "id_dsa")); //$NON-NLS-1$
}
}
private static void loadIdentity(JSch sch, File priv) {
if (priv.isFile()) {
try {
sch.addIdentity(priv.getAbsolutePath());
} catch (JSchException e) {
// Instead, pretend the key doesn't exist.
}
}
}
private static class JschBugFixingConfigRepository
implements ConfigRepository {
private final ConfigRepository base;
public JschBugFixingConfigRepository(ConfigRepository base) {
this.base = base;
}
@Override
public Config getConfig(String host) {
return new JschBugFixingConfig(base.getConfig(host));
}
/**
* A {@link com.jcraft.jsch.ConfigRepository.Config} that transforms
* some values from the config file into the format Jsch 0.1.54 expects.
* This is a work-around for bugs in Jsch.
* <p>
* Additionally, this config hides the IdentityFile config entries from
* Jsch; we manage those ourselves. Otherwise Jsch would cache passwords
* (or rather, decrypted keys) only for a single session, resulting in
* multiple password prompts for user operations that use several Jsch
* sessions.
*/
private static class JschBugFixingConfig implements Config {
private static final String[] NO_IDENTITIES = {};
private final Config real;
public JschBugFixingConfig(Config delegate) {
real = delegate;
}
@Override
public String getHostname() {
return real.getHostname();
}
@Override
public String getUser() {
return real.getUser();
}
@Override
public int getPort() {
return real.getPort();
}
@Override
public String getValue(String key) {
String k = key.toUpperCase(Locale.ROOT);
if ("IDENTITYFILE".equals(k)) { //$NON-NLS-1$
return null;
}
String result = real.getValue(key);
if (result != null) {
if ("SERVERALIVEINTERVAL".equals(k) //$NON-NLS-1$
|| "CONNECTTIMEOUT".equals(k)) { //$NON-NLS-1$
// These values are in seconds. Jsch 0.1.54 passes them
// on as is to java.net.Socket.setSoTimeout(), which
// expects milliseconds. So convert here to
// milliseconds.
try {
int timeout = Integer.parseInt(result);
result = Long.toString(
TimeUnit.SECONDS.toMillis(timeout));
} catch (NumberFormatException e) {
// Ignore
}
}
}
return result;
}
@Override
public String[] getValues(String key) {
String k = key.toUpperCase(Locale.ROOT);
if ("IDENTITYFILE".equals(k)) { //$NON-NLS-1$
return NO_IDENTITIES;
}
return real.getValues(key);
}
}
}
/**
* Set the {@link OpenSshConfig} to use. Intended for use in tests.
*
* @param config
* to use
*/
void setConfig(OpenSshConfig config) {
this.config = config;
}
}