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