xref: /JGit/org.eclipse.jgit.ssh.jsch/src/org/eclipse/jgit/transport/JschConfigSessionFactory.java (revision 8d2d683655e2de17cf465fa46af10e0e56b3aaed)
1 /*
2  * Copyright (C) 2018, Sasa Zivkov <sasa.zivkov@sap.com>
3  * Copyright (C) 2016, Mark Ingram <markdingram@gmail.com>
4  * Copyright (C) 2009, Constantine Plotnikov <constantine.plotnikov@gmail.com>
5  * Copyright (C) 2008-2009, Google Inc.
6  * Copyright (C) 2009, Google, Inc.
7  * Copyright (C) 2009, JetBrains s.r.o.
8  * Copyright (C) 2008, Robin Rosenberg <robin.rosenberg@dewire.com>
9  * Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org> and others
10  *
11  * This program and the accompanying materials are made available under the
12  * terms of the Eclipse Distribution License v. 1.0 which is available at
13  * https://www.eclipse.org/org/documents/edl-v10.php.
14  *
15  * SPDX-License-Identifier: BSD-3-Clause
16  */
17 
18 //TODO(ms): move to org.eclipse.jgit.ssh.jsch in 6.0
19 package org.eclipse.jgit.transport;
20 
21 import static java.util.stream.Collectors.joining;
22 import static java.util.stream.Collectors.toList;
23 
24 import java.io.File;
25 import java.io.FileInputStream;
26 import java.io.FileNotFoundException;
27 import java.io.IOException;
28 import java.lang.reflect.InvocationTargetException;
29 import java.lang.reflect.Method;
30 import java.net.ConnectException;
31 import java.net.UnknownHostException;
32 import java.text.MessageFormat;
33 import java.util.HashMap;
34 import java.util.List;
35 import java.util.Locale;
36 import java.util.Map;
37 import java.util.concurrent.TimeUnit;
38 import java.util.stream.Stream;
39 
40 import org.eclipse.jgit.errors.TransportException;
41 import org.eclipse.jgit.internal.transport.jsch.JSchText;
42 import org.eclipse.jgit.util.FS;
43 import org.slf4j.Logger;
44 import org.slf4j.LoggerFactory;
45 
46 import com.jcraft.jsch.ConfigRepository;
47 import com.jcraft.jsch.ConfigRepository.Config;
48 import com.jcraft.jsch.HostKey;
49 import com.jcraft.jsch.HostKeyRepository;
50 import com.jcraft.jsch.JSch;
51 import com.jcraft.jsch.JSchException;
52 import com.jcraft.jsch.Session;
53 
54 /**
55  * The base session factory that loads known hosts and private keys from
56  * <code>$HOME/.ssh</code>.
57  * <p>
58  * This is the default implementation used by JGit and provides most of the
59  * compatibility necessary to match OpenSSH, a popular implementation of SSH
60  * used by C Git.
61  * <p>
62  * The factory does not provide UI behavior. Override the method
63  * {@link #configure(org.eclipse.jgit.transport.OpenSshConfig.Host, Session)} to
64  * supply appropriate {@link com.jcraft.jsch.UserInfo} to the session.
65  */
66 public class JschConfigSessionFactory extends SshSessionFactory {
67 
68 	private static final String JSCH = "jsch"; //$NON-NLS-1$
69 
70 	private static final Logger LOG = LoggerFactory
71 			.getLogger(JschConfigSessionFactory.class);
72 
73 	/**
74 	 * We use different Jsch instances for hosts that have an IdentityFile
75 	 * configured in ~/.ssh/config. Jsch by default would cache decrypted keys
76 	 * only per session, which results in repeated password prompts. Using
77 	 * different Jsch instances, we can cache the keys on these instances so
78 	 * that they will be re-used for successive sessions, and thus the user is
79 	 * prompted for a key password only once while Eclipse runs.
80 	 */
81 	private final Map<String, JSch> byIdentityFile = new HashMap<>();
82 
83 	private JSch defaultJSch;
84 
85 	private OpenSshConfig config;
86 
87 	/** {@inheritDoc} */
88 	@Override
getSession(URIish uri, CredentialsProvider credentialsProvider, FS fs, int tms)89 	public synchronized RemoteSession getSession(URIish uri,
90 			CredentialsProvider credentialsProvider, FS fs, int tms)
91 			throws TransportException {
92 
93 		String user = uri.getUser();
94 		final String pass = uri.getPass();
95 		String host = uri.getHost();
96 		int port = uri.getPort();
97 
98 		try {
99 			if (config == null)
100 				config = OpenSshConfig.get(fs);
101 
102 			final OpenSshConfig.Host hc = config.lookup(host);
103 			if (port <= 0)
104 				port = hc.getPort();
105 			if (user == null)
106 				user = hc.getUser();
107 
108 			Session session = createSession(credentialsProvider, fs, user,
109 					pass, host, port, hc);
110 
111 			int retries = 0;
112 			while (!session.isConnected()) {
113 				try {
114 					retries++;
115 					session.connect(tms);
116 				} catch (JSchException e) {
117 					session.disconnect();
118 					session = null;
119 					// Make sure our known_hosts is not outdated
120 					knownHosts(getJSch(hc, fs), fs);
121 
122 					if (isAuthenticationCanceled(e)) {
123 						throw e;
124 					} else if (isAuthenticationFailed(e)
125 							&& credentialsProvider != null) {
126 						// if authentication failed maybe credentials changed at
127 						// the remote end therefore reset credentials and retry
128 						if (retries < 3) {
129 							credentialsProvider.reset(uri);
130 							session = createSession(credentialsProvider, fs,
131 									user, pass, host, port, hc);
132 						} else
133 							throw e;
134 					} else if (retries >= hc.getConnectionAttempts()) {
135 						throw e;
136 					} else {
137 						try {
138 							Thread.sleep(1000);
139 							session = createSession(credentialsProvider, fs,
140 									user, pass, host, port, hc);
141 						} catch (InterruptedException e1) {
142 							throw new TransportException(
143 									JSchText.get().transportSSHRetryInterrupt,
144 									e1);
145 						}
146 					}
147 				}
148 			}
149 
150 			return new JschSession(session, uri);
151 
152 		} catch (JSchException je) {
153 			final Throwable c = je.getCause();
154 			if (c instanceof UnknownHostException) {
155 				throw new TransportException(uri,
156 						JSchText.get().unknownHost,
157 						je);
158 			}
159 			if (c instanceof ConnectException) {
160 				throw new TransportException(uri, c.getMessage(), je);
161 			}
162 			throw new TransportException(uri, je.getMessage(), je);
163 		}
164 
165 	}
166 
167 	@Override
getType()168 	public String getType() {
169 		return JSCH;
170 	}
171 
isAuthenticationFailed(JSchException e)172 	private static boolean isAuthenticationFailed(JSchException e) {
173 		return e.getCause() == null && e.getMessage().equals("Auth fail"); //$NON-NLS-1$
174 	}
175 
isAuthenticationCanceled(JSchException e)176 	private static boolean isAuthenticationCanceled(JSchException e) {
177 		return e.getCause() == null && e.getMessage().equals("Auth cancel"); //$NON-NLS-1$
178 	}
179 
180 	// Package visibility for tests
createSession(CredentialsProvider credentialsProvider, FS fs, String user, final String pass, String host, int port, final OpenSshConfig.Host hc)181 	Session createSession(CredentialsProvider credentialsProvider,
182 			FS fs, String user, final String pass, String host, int port,
183 			final OpenSshConfig.Host hc) throws JSchException {
184 		final Session session = createSession(hc, user, host, port, fs);
185 		// Jsch will have overridden the explicit user by the one from the SSH
186 		// config file...
187 		setUserName(session, user);
188 		// Jsch will also have overridden the port.
189 		if (port > 0 && port != session.getPort()) {
190 			session.setPort(port);
191 		}
192 		// We retry already in getSession() method. JSch must not retry
193 		// on its own.
194 		session.setConfig("MaxAuthTries", "1"); //$NON-NLS-1$ //$NON-NLS-2$
195 		if (pass != null)
196 			session.setPassword(pass);
197 		final String strictHostKeyCheckingPolicy = hc
198 				.getStrictHostKeyChecking();
199 		if (strictHostKeyCheckingPolicy != null)
200 			session.setConfig("StrictHostKeyChecking", //$NON-NLS-1$
201 					strictHostKeyCheckingPolicy);
202 		final String pauth = hc.getPreferredAuthentications();
203 		if (pauth != null)
204 			session.setConfig("PreferredAuthentications", pauth); //$NON-NLS-1$
205 		if (credentialsProvider != null
206 				&& (!hc.isBatchMode() || !credentialsProvider.isInteractive())) {
207 			session.setUserInfo(new CredentialsProviderUserInfo(session,
208 					credentialsProvider));
209 		}
210 		safeConfig(session, hc.getConfig());
211 		if (hc.getConfig().getValue("HostKeyAlgorithms") == null) { //$NON-NLS-1$
212 			setPreferredKeyTypesOrder(session);
213 		}
214 		configure(hc, session);
215 		return session;
216 	}
217 
safeConfig(Session session, Config cfg)218 	private void safeConfig(Session session, Config cfg) {
219 		// Ensure that Jsch checks all configured algorithms, not just its
220 		// built-in ones. Otherwise it may propose an algorithm for which it
221 		// doesn't have an implementation, and then run into an NPE if that
222 		// algorithm ends up being chosen.
223 		copyConfigValueToSession(session, cfg, "Ciphers", "CheckCiphers"); //$NON-NLS-1$ //$NON-NLS-2$
224 		copyConfigValueToSession(session, cfg, "KexAlgorithms", "CheckKexes"); //$NON-NLS-1$ //$NON-NLS-2$
225 		copyConfigValueToSession(session, cfg, "HostKeyAlgorithms", //$NON-NLS-1$
226 				"CheckSignatures"); //$NON-NLS-1$
227 	}
228 
setPreferredKeyTypesOrder(Session session)229 	private static void setPreferredKeyTypesOrder(Session session) {
230 		HostKeyRepository hkr = session.getHostKeyRepository();
231 		HostKey[] hostKeys = hkr.getHostKey(hostName(session), null);
232 
233 		if (hostKeys == null) {
234 			return;
235 		}
236 
237 		List<String> known = Stream.of(hostKeys)
238 				.map(HostKey::getType)
239 				.collect(toList());
240 
241 		if (!known.isEmpty()) {
242 			String serverHostKey = "server_host_key"; //$NON-NLS-1$
243 			String current = session.getConfig(serverHostKey);
244 			if (current == null) {
245 				session.setConfig(serverHostKey, String.join(",", known)); //$NON-NLS-1$
246 				return;
247 			}
248 
249 			String knownFirst = Stream.concat(
250 							known.stream(),
251 							Stream.of(current.split(",")) //$NON-NLS-1$
252 									.filter(s -> !known.contains(s)))
253 					.collect(joining(",")); //$NON-NLS-1$
254 			session.setConfig(serverHostKey, knownFirst);
255 		}
256 	}
257 
hostName(Session s)258 	private static String hostName(Session s) {
259 		if (s.getPort() == SshConstants.SSH_DEFAULT_PORT) {
260 			return s.getHost();
261 		}
262 		return String.format("[%s]:%d", s.getHost(), //$NON-NLS-1$
263 				Integer.valueOf(s.getPort()));
264 	}
265 
copyConfigValueToSession(Session session, Config cfg, String from, String to)266 	private void copyConfigValueToSession(Session session, Config cfg,
267 			String from, String to) {
268 		String value = cfg.getValue(from);
269 		if (value != null) {
270 			session.setConfig(to, value);
271 		}
272 	}
273 
setUserName(Session session, String userName)274 	private void setUserName(Session session, String userName) {
275 		// Jsch 0.1.54 picks up the user name from the ssh config, even if an
276 		// explicit user name was given! We must correct that if ~/.ssh/config
277 		// has a different user name.
278 		if (userName == null || userName.isEmpty()
279 				|| userName.equals(session.getUserName())) {
280 			return;
281 		}
282 		try {
283 			Class<?>[] parameterTypes = { String.class };
284 			Method method = Session.class.getDeclaredMethod("setUserName", //$NON-NLS-1$
285 					parameterTypes);
286 			method.setAccessible(true);
287 			method.invoke(session, userName);
288 		} catch (NullPointerException | IllegalAccessException
289 				| IllegalArgumentException | InvocationTargetException
290 				| NoSuchMethodException | SecurityException e) {
291 			LOG.error(MessageFormat.format(JSchText.get().sshUserNameError,
292 					userName, session.getUserName()), e);
293 		}
294 	}
295 
296 	/**
297 	 * Create a new remote session for the requested address.
298 	 *
299 	 * @param hc
300 	 *            host configuration
301 	 * @param user
302 	 *            login to authenticate as.
303 	 * @param host
304 	 *            server name to connect to.
305 	 * @param port
306 	 *            port number of the SSH daemon (typically 22).
307 	 * @param fs
308 	 *            the file system abstraction which will be necessary to
309 	 *            perform certain file system operations.
310 	 * @return new session instance, but otherwise unconfigured.
311 	 * @throws com.jcraft.jsch.JSchException
312 	 *             the session could not be created.
313 	 */
createSession(final OpenSshConfig.Host hc, final String user, final String host, final int port, FS fs)314 	protected Session createSession(final OpenSshConfig.Host hc,
315 			final String user, final String host, final int port, FS fs)
316 			throws JSchException {
317 		return getJSch(hc, fs).getSession(user, host, port);
318 	}
319 
320 	/**
321 	 * Provide additional configuration for the JSch instance. This method could
322 	 * be overridden to supply a preferred
323 	 * {@link com.jcraft.jsch.IdentityRepository}.
324 	 *
325 	 * @param jsch
326 	 *            jsch instance
327 	 * @since 4.5
328 	 */
configureJSch(JSch jsch)329 	protected void configureJSch(JSch jsch) {
330 		// No additional configuration required.
331 	}
332 
333 	/**
334 	 * Provide additional configuration for the session based on the host
335 	 * information. This method could be used to supply
336 	 * {@link com.jcraft.jsch.UserInfo}.
337 	 *
338 	 * @param hc
339 	 *            host configuration
340 	 * @param session
341 	 *            session to configure
342 	 */
configure(OpenSshConfig.Host hc, Session session)343 	protected void configure(OpenSshConfig.Host hc, Session session) {
344 		// No additional configuration required.
345 	}
346 
347 	/**
348 	 * Obtain the JSch used to create new sessions.
349 	 *
350 	 * @param hc
351 	 *            host configuration
352 	 * @param fs
353 	 *            the file system abstraction which will be necessary to
354 	 *            perform certain file system operations.
355 	 * @return the JSch instance to use.
356 	 * @throws com.jcraft.jsch.JSchException
357 	 *             the user configuration could not be created.
358 	 */
getJSch(OpenSshConfig.Host hc, FS fs)359 	protected JSch getJSch(OpenSshConfig.Host hc, FS fs) throws JSchException {
360 		if (defaultJSch == null) {
361 			defaultJSch = createDefaultJSch(fs);
362 			if (defaultJSch.getConfigRepository() == null) {
363 				defaultJSch.setConfigRepository(
364 						new JschBugFixingConfigRepository(config));
365 			}
366 			for (Object name : defaultJSch.getIdentityNames())
367 				byIdentityFile.put((String) name, defaultJSch);
368 		}
369 
370 		final File identityFile = hc.getIdentityFile();
371 		if (identityFile == null)
372 			return defaultJSch;
373 
374 		final String identityKey = identityFile.getAbsolutePath();
375 		JSch jsch = byIdentityFile.get(identityKey);
376 		if (jsch == null) {
377 			jsch = new JSch();
378 			configureJSch(jsch);
379 			if (jsch.getConfigRepository() == null) {
380 				jsch.setConfigRepository(defaultJSch.getConfigRepository());
381 			}
382 			jsch.setHostKeyRepository(defaultJSch.getHostKeyRepository());
383 			jsch.addIdentity(identityKey);
384 			byIdentityFile.put(identityKey, jsch);
385 		}
386 		return jsch;
387 	}
388 
389 	/**
390 	 * Create default instance of jsch
391 	 *
392 	 * @param fs
393 	 *            the file system abstraction which will be necessary to perform
394 	 *            certain file system operations.
395 	 * @return the new default JSch implementation.
396 	 * @throws com.jcraft.jsch.JSchException
397 	 *             known host keys cannot be loaded.
398 	 */
createDefaultJSch(FS fs)399 	protected JSch createDefaultJSch(FS fs) throws JSchException {
400 		final JSch jsch = new JSch();
401 		JSch.setConfig("ssh-rsa", JSch.getConfig("signature.rsa")); //$NON-NLS-1$ //$NON-NLS-2$
402 		JSch.setConfig("ssh-dss", JSch.getConfig("signature.dss")); //$NON-NLS-1$ //$NON-NLS-2$
403 		configureJSch(jsch);
404 		knownHosts(jsch, fs);
405 		identities(jsch, fs);
406 		return jsch;
407 	}
408 
knownHosts(JSch sch, FS fs)409 	private static void knownHosts(JSch sch, FS fs) throws JSchException {
410 		final File home = fs.userHome();
411 		if (home == null)
412 			return;
413 		final File known_hosts = new File(new File(home, ".ssh"), "known_hosts"); //$NON-NLS-1$ //$NON-NLS-2$
414 		try (FileInputStream in = new FileInputStream(known_hosts)) {
415 			sch.setKnownHosts(in);
416 		} catch (FileNotFoundException none) {
417 			// Oh well. They don't have a known hosts in home.
418 		} catch (IOException err) {
419 			// Oh well. They don't have a known hosts in home.
420 		}
421 	}
422 
identities(JSch sch, FS fs)423 	private static void identities(JSch sch, FS fs) {
424 		final File home = fs.userHome();
425 		if (home == null)
426 			return;
427 		final File sshdir = new File(home, ".ssh"); //$NON-NLS-1$
428 		if (sshdir.isDirectory()) {
429 			loadIdentity(sch, new File(sshdir, "identity")); //$NON-NLS-1$
430 			loadIdentity(sch, new File(sshdir, "id_rsa")); //$NON-NLS-1$
431 			loadIdentity(sch, new File(sshdir, "id_dsa")); //$NON-NLS-1$
432 		}
433 	}
434 
loadIdentity(JSch sch, File priv)435 	private static void loadIdentity(JSch sch, File priv) {
436 		if (priv.isFile()) {
437 			try {
438 				sch.addIdentity(priv.getAbsolutePath());
439 			} catch (JSchException e) {
440 				// Instead, pretend the key doesn't exist.
441 			}
442 		}
443 	}
444 
445 	private static class JschBugFixingConfigRepository
446 			implements ConfigRepository {
447 
448 		private final ConfigRepository base;
449 
JschBugFixingConfigRepository(ConfigRepository base)450 		public JschBugFixingConfigRepository(ConfigRepository base) {
451 			this.base = base;
452 		}
453 
454 		@Override
getConfig(String host)455 		public Config getConfig(String host) {
456 			return new JschBugFixingConfig(base.getConfig(host));
457 		}
458 
459 		/**
460 		 * A {@link com.jcraft.jsch.ConfigRepository.Config} that transforms
461 		 * some values from the config file into the format Jsch 0.1.54 expects.
462 		 * This is a work-around for bugs in Jsch.
463 		 * <p>
464 		 * Additionally, this config hides the IdentityFile config entries from
465 		 * Jsch; we manage those ourselves. Otherwise Jsch would cache passwords
466 		 * (or rather, decrypted keys) only for a single session, resulting in
467 		 * multiple password prompts for user operations that use several Jsch
468 		 * sessions.
469 		 */
470 		private static class JschBugFixingConfig implements Config {
471 
472 			private static final String[] NO_IDENTITIES = {};
473 
474 			private final Config real;
475 
JschBugFixingConfig(Config delegate)476 			public JschBugFixingConfig(Config delegate) {
477 				real = delegate;
478 			}
479 
480 			@Override
getHostname()481 			public String getHostname() {
482 				return real.getHostname();
483 			}
484 
485 			@Override
getUser()486 			public String getUser() {
487 				return real.getUser();
488 			}
489 
490 			@Override
getPort()491 			public int getPort() {
492 				return real.getPort();
493 			}
494 
495 			@Override
getValue(String key)496 			public String getValue(String key) {
497 				String k = key.toUpperCase(Locale.ROOT);
498 				if ("IDENTITYFILE".equals(k)) { //$NON-NLS-1$
499 					return null;
500 				}
501 				String result = real.getValue(key);
502 				if (result != null) {
503 					if ("SERVERALIVEINTERVAL".equals(k) //$NON-NLS-1$
504 							|| "CONNECTTIMEOUT".equals(k)) { //$NON-NLS-1$
505 						// These values are in seconds. Jsch 0.1.54 passes them
506 						// on as is to java.net.Socket.setSoTimeout(), which
507 						// expects milliseconds. So convert here to
508 						// milliseconds.
509 						try {
510 							int timeout = Integer.parseInt(result);
511 							result = Long.toString(
512 									TimeUnit.SECONDS.toMillis(timeout));
513 						} catch (NumberFormatException e) {
514 							// Ignore
515 						}
516 					}
517 				}
518 				return result;
519 			}
520 
521 			@Override
getValues(String key)522 			public String[] getValues(String key) {
523 				String k = key.toUpperCase(Locale.ROOT);
524 				if ("IDENTITYFILE".equals(k)) { //$NON-NLS-1$
525 					return NO_IDENTITIES;
526 				}
527 				return real.getValues(key);
528 			}
529 		}
530 	}
531 
532 	/**
533 	 * Set the {@link OpenSshConfig} to use. Intended for use in tests.
534 	 *
535 	 * @param config
536 	 *            to use
537 	 */
setConfig(OpenSshConfig config)538 	synchronized void setConfig(OpenSshConfig config) {
539 		this.config = config;
540 	}
541 }
542