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  //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
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
168 	public String getType() {
169 		return JSCH;
170 	}
171 
172 	private static boolean isAuthenticationFailed(JSchException e) {
173 		return e.getCause() == null && e.getMessage().equals("Auth fail"); //$NON-NLS-1$
174 	}
175 
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
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 
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 
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 
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 
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 
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 	 */
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 	 */
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 	 */
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 	 */
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 	 */
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 
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 
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 
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 
450 		public JschBugFixingConfigRepository(ConfigRepository base) {
451 			this.base = base;
452 		}
453 
454 		@Override
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 
476 			public JschBugFixingConfig(Config delegate) {
477 				real = delegate;
478 			}
479 
480 			@Override
481 			public String getHostname() {
482 				return real.getHostname();
483 			}
484 
485 			@Override
486 			public String getUser() {
487 				return real.getUser();
488 			}
489 
490 			@Override
491 			public int getPort() {
492 				return real.getPort();
493 			}
494 
495 			@Override
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
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 	 */
538 	synchronized void setConfig(OpenSshConfig config) {
539 		this.config = config;
540 	}
541 }