View Javadoc
1   /*
2    * Copyright (C) 2018, 2019 Thomas Wolf <thomas.wolf@paranor.ch> and others
3    *
4    * This program and the accompanying materials are made available under the
5    * terms of the Eclipse Distribution License v. 1.0 which is available at
6    * https://www.eclipse.org/org/documents/edl-v10.php.
7    *
8    * SPDX-License-Identifier: BSD-3-Clause
9    */
10  package org.eclipse.jgit.transport.sshd;
11  
12  import java.io.Closeable;
13  import java.io.File;
14  import java.io.IOException;
15  import java.nio.file.Files;
16  import java.nio.file.Path;
17  import java.security.KeyPair;
18  import java.time.Duration;
19  import java.util.ArrayList;
20  import java.util.Arrays;
21  import java.util.Collections;
22  import java.util.HashSet;
23  import java.util.List;
24  import java.util.Map;
25  import java.util.Set;
26  import java.util.concurrent.ConcurrentHashMap;
27  import java.util.concurrent.atomic.AtomicBoolean;
28  import java.util.stream.Collectors;
29  
30  import org.apache.sshd.client.ClientBuilder;
31  import org.apache.sshd.client.SshClient;
32  import org.apache.sshd.client.auth.UserAuthFactory;
33  import org.apache.sshd.client.auth.keyboard.UserAuthKeyboardInteractiveFactory;
34  import org.apache.sshd.client.auth.pubkey.UserAuthPublicKeyFactory;
35  import org.apache.sshd.client.config.hosts.HostConfigEntryResolver;
36  import org.apache.sshd.common.compression.BuiltinCompressions;
37  import org.apache.sshd.common.config.keys.FilePasswordProvider;
38  import org.apache.sshd.common.config.keys.loader.openssh.kdf.BCryptKdfOptions;
39  import org.apache.sshd.common.keyprovider.KeyIdentityProvider;
40  import org.eclipse.jgit.annotations.NonNull;
41  import org.eclipse.jgit.errors.TransportException;
42  import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile;
43  import org.eclipse.jgit.internal.transport.sshd.CachingKeyPairProvider;
44  import org.eclipse.jgit.internal.transport.sshd.GssApiWithMicAuthFactory;
45  import org.eclipse.jgit.internal.transport.sshd.JGitPasswordAuthFactory;
46  import org.eclipse.jgit.internal.transport.sshd.JGitServerKeyVerifier;
47  import org.eclipse.jgit.internal.transport.sshd.JGitSshClient;
48  import org.eclipse.jgit.internal.transport.sshd.JGitSshConfig;
49  import org.eclipse.jgit.internal.transport.sshd.JGitUserInteraction;
50  import org.eclipse.jgit.internal.transport.sshd.OpenSshServerKeyDatabase;
51  import org.eclipse.jgit.internal.transport.sshd.PasswordProviderWrapper;
52  import org.eclipse.jgit.internal.transport.sshd.SshdText;
53  import org.eclipse.jgit.transport.CredentialsProvider;
54  import org.eclipse.jgit.transport.SshConfigStore;
55  import org.eclipse.jgit.transport.SshConstants;
56  import org.eclipse.jgit.transport.SshSessionFactory;
57  import org.eclipse.jgit.transport.URIish;
58  import org.eclipse.jgit.util.FS;
59  
60  /**
61   * A {@link SshSessionFactory} that uses Apache MINA sshd. Classes from Apache
62   * MINA sshd are kept private to avoid API evolution problems when Apache MINA
63   * sshd interfaces change.
64   *
65   * @since 5.2
66   */
67  public class SshdSessionFactory extends SshSessionFactory implements Closeable {
68  
69  	private static final String MINA_SSHD = "mina-sshd"; //$NON-NLS-1$
70  
71  	private final AtomicBoolean closing = new AtomicBoolean();
72  
73  	private final Set<SshdSession> sessions = new HashSet<>();
74  
75  	private final Map<Tuple, HostConfigEntryResolver> defaultHostConfigEntryResolver = new ConcurrentHashMap<>();
76  
77  	private final Map<Tuple, ServerKeyDatabase> defaultServerKeyDatabase = new ConcurrentHashMap<>();
78  
79  	private final Map<Tuple, Iterable<KeyPair>> defaultKeys = new ConcurrentHashMap<>();
80  
81  	private final KeyCache keyCache;
82  
83  	private final ProxyDataFactory proxies;
84  
85  	private File sshDirectory;
86  
87  	private File homeDirectory;
88  
89  	/**
90  	 * Creates a new {@link SshdSessionFactory} without key cache and a
91  	 * {@link DefaultProxyDataFactory}.
92  	 */
93  	public SshdSessionFactory() {
94  		this(null, new DefaultProxyDataFactory());
95  	}
96  
97  	/**
98  	 * Creates a new {@link SshdSessionFactory} using the given {@link KeyCache}
99  	 * and {@link ProxyDataFactory}. The {@code keyCache} is used for all sessions
100 	 * created through this session factory; cached keys are destroyed when the
101 	 * session factory is {@link #close() closed}.
102 	 * <p>
103 	 * Caching ssh keys in memory for an extended period of time is generally
104 	 * considered bad practice, but there may be circumstances where using a
105 	 * {@link KeyCache} is still the right choice, for instance to avoid that a
106 	 * user gets prompted several times for the same password for the same key.
107 	 * In general, however, it is preferable <em>not</em> to use a key cache but
108 	 * to use a {@link #createKeyPasswordProvider(CredentialsProvider)
109 	 * KeyPasswordProvider} that has access to some secure storage and can save
110 	 * and retrieve passwords from there without user interaction. Another
111 	 * approach is to use an ssh agent.
112 	 * </p>
113 	 * <p>
114 	 * Note that the underlying ssh library (Apache MINA sshd) may or may not
115 	 * keep ssh keys in memory for unspecified periods of time irrespective of
116 	 * the use of a {@link KeyCache}.
117 	 * </p>
118 	 *
119 	 * @param keyCache
120 	 *            {@link KeyCache} to use for caching ssh keys, or {@code null}
121 	 *            to not use a key cache
122 	 * @param proxies
123 	 *            {@link ProxyDataFactory} to use, or {@code null} to not use a
124 	 *            proxy database (in which case connections through proxies will
125 	 *            not be possible)
126 	 */
127 	public SshdSessionFactory(KeyCache keyCache, ProxyDataFactory proxies) {
128 		super();
129 		this.keyCache = keyCache;
130 		this.proxies = proxies;
131 		// sshd limits the number of BCrypt KDF rounds to 255 by default.
132 		// Decrypting such a key takes about two seconds on my machine.
133 		// I consider this limit too low. The time increases linearly with the
134 		// number of rounds.
135 		BCryptKdfOptions.setMaxAllowedRounds(16384);
136 	}
137 
138 	@Override
139 	public String getType() {
140 		return MINA_SSHD;
141 	}
142 
143 	/** A simple general map key. */
144 	private static final class Tuple {
145 		private Object[] objects;
146 
147 		public Tuple(Object[] objects) {
148 			this.objects = objects;
149 		}
150 
151 		@Override
152 		public boolean equals(Object obj) {
153 			if (obj == this) {
154 				return true;
155 			}
156 			if (obj != null && obj.getClass() == Tuple.class) {
157 				Tuple other = (Tuple) obj;
158 				return Arrays.equals(objects, other.objects);
159 			}
160 			return false;
161 		}
162 
163 		@Override
164 		public int hashCode() {
165 			return Arrays.hashCode(objects);
166 		}
167 	}
168 
169 	// We can't really use a single client. Clients need to be stopped
170 	// properly, and we don't really know when to do that. Instead we use
171 	// a dedicated SshClient instance per session. We need a bit of caching to
172 	// avoid re-loading the ssh config and keys repeatedly.
173 
174 	@Override
175 	public SshdSession getSession(URIish uri,
176 			CredentialsProvider credentialsProvider, FS fs, int tms)
177 			throws TransportException {
178 		SshdSession session = null;
179 		try {
180 			session = new SshdSession(uri, () -> {
181 				File home = getHomeDirectory();
182 				if (home == null) {
183 					// Always use the detected filesystem for the user home!
184 					// It makes no sense to have different "user home"
185 					// directories depending on what file system a repository
186 					// is.
187 					home = FS.DETECTED.userHome();
188 				}
189 				File sshDir = getSshDirectory();
190 				if (sshDir == null) {
191 					sshDir = new File(home, SshConstants.SSH_DIR);
192 				}
193 				HostConfigEntryResolver configFile = getHostConfigEntryResolver(
194 						home, sshDir);
195 				KeyIdentityProvider defaultKeysProvider = toKeyIdentityProvider(
196 						getDefaultKeys(sshDir));
197 				KeyPasswordProvider passphrases = createKeyPasswordProvider(
198 						credentialsProvider);
199 				SshClient client = ClientBuilder.builder()
200 						.factory(JGitSshClient::new)
201 						.filePasswordProvider(
202 								createFilePasswordProvider(passphrases))
203 						.hostConfigEntryResolver(configFile)
204 						.serverKeyVerifier(new JGitServerKeyVerifier(
205 								getServerKeyDatabase(home, sshDir)))
206 						.compressionFactories(
207 								new ArrayList<>(BuiltinCompressions.VALUES))
208 						.build();
209 				client.setUserInteraction(
210 						new JGitUserInteraction(credentialsProvider));
211 				client.setUserAuthFactories(getUserAuthFactories());
212 				client.setKeyIdentityProvider(defaultKeysProvider);
213 				// JGit-specific things:
214 				JGitSshClient jgitClient = (JGitSshClient) client;
215 				jgitClient.setKeyCache(getKeyCache());
216 				jgitClient.setCredentialsProvider(credentialsProvider);
217 				jgitClient.setProxyDatabase(proxies);
218 				String defaultAuths = getDefaultPreferredAuthentications();
219 				if (defaultAuths != null) {
220 					jgitClient.setAttribute(
221 							JGitSshClient.PREFERRED_AUTHENTICATIONS,
222 							defaultAuths);
223 				}
224 				// Other things?
225 				return client;
226 			});
227 			session.addCloseListener(s -> unregister(s));
228 			register(session);
229 			session.connect(Duration.ofMillis(tms));
230 			return session;
231 		} catch (Exception e) {
232 			unregister(session);
233 			throw new TransportException(uri, e.getMessage(), e);
234 		}
235 	}
236 
237 	@Override
238 	public void close() {
239 		closing.set(true);
240 		boolean cleanKeys = false;
241 		synchronized (this) {
242 			cleanKeys = sessions.isEmpty();
243 		}
244 		if (cleanKeys) {
245 			KeyCache cache = getKeyCache();
246 			if (cache != null) {
247 				cache.close();
248 			}
249 		}
250 	}
251 
252 	private void register(SshdSession newSession) throws IOException {
253 		if (newSession == null) {
254 			return;
255 		}
256 		if (closing.get()) {
257 			throw new IOException(SshdText.get().sshClosingDown);
258 		}
259 		synchronized (this) {
260 			sessions.add(newSession);
261 		}
262 	}
263 
264 	private void unregister(SshdSession oldSession) {
265 		boolean cleanKeys = false;
266 		synchronized (this) {
267 			sessions.remove(oldSession);
268 			cleanKeys = closing.get() && sessions.isEmpty();
269 		}
270 		if (cleanKeys) {
271 			KeyCache cache = getKeyCache();
272 			if (cache != null) {
273 				cache.close();
274 			}
275 		}
276 	}
277 
278 	/**
279 	 * Set a global directory to use as the user's home directory
280 	 *
281 	 * @param homeDir
282 	 *            to use
283 	 */
284 	public void setHomeDirectory(@NonNull File homeDir) {
285 		if (homeDir.isAbsolute()) {
286 			homeDirectory = homeDir;
287 		} else {
288 			homeDirectory = homeDir.getAbsoluteFile();
289 		}
290 	}
291 
292 	/**
293 	 * Retrieves the global user home directory
294 	 *
295 	 * @return the directory, or {@code null} if not set
296 	 */
297 	public File getHomeDirectory() {
298 		return homeDirectory;
299 	}
300 
301 	/**
302 	 * Set a global directory to use as the .ssh directory
303 	 *
304 	 * @param sshDir
305 	 *            to use
306 	 */
307 	public void setSshDirectory(@NonNull File sshDir) {
308 		if (sshDir.isAbsolute()) {
309 			sshDirectory = sshDir;
310 		} else {
311 			sshDirectory = sshDir.getAbsoluteFile();
312 		}
313 	}
314 
315 	/**
316 	 * Retrieves the global .ssh directory
317 	 *
318 	 * @return the directory, or {@code null} if not set
319 	 */
320 	public File getSshDirectory() {
321 		return sshDirectory;
322 	}
323 
324 	/**
325 	 * Obtain a {@link HostConfigEntryResolver} to read the ssh config file and
326 	 * to determine host entries for connections.
327 	 *
328 	 * @param homeDir
329 	 *            home directory to use for ~ replacement
330 	 * @param sshDir
331 	 *            to use for looking for the config file
332 	 * @return the resolver
333 	 */
334 	@NonNull
335 	private HostConfigEntryResolver getHostConfigEntryResolver(
336 			@NonNull File homeDir, @NonNull File sshDir) {
337 		return defaultHostConfigEntryResolver.computeIfAbsent(
338 				new Tuple(new Object[] { homeDir, sshDir }),
339 				t -> new JGitSshConfig(createSshConfigStore(homeDir,
340 						getSshConfig(sshDir), getLocalUserName())));
341 	}
342 
343 	/**
344 	 * Determines the ssh config file. The default implementation returns
345 	 * ~/.ssh/config. If the file does not exist and is created later it will be
346 	 * picked up. To not use a config file at all, return {@code null}.
347 	 *
348 	 * @param sshDir
349 	 *            representing ~/.ssh/
350 	 * @return the file (need not exist), or {@code null} if no config file
351 	 *         shall be used
352 	 * @since 5.5
353 	 */
354 	protected File getSshConfig(@NonNull File sshDir) {
355 		return new File(sshDir, SshConstants.CONFIG);
356 	}
357 
358 	/**
359 	 * Obtains a {@link SshConfigStore}, or {@code null} if not SSH config is to
360 	 * be used. The default implementation returns {@code null} if
361 	 * {@code configFile == null} and otherwise an OpenSSH-compatible store
362 	 * reading host entries from the given file.
363 	 *
364 	 * @param homeDir
365 	 *            may be used for ~-replacements by the returned config store
366 	 * @param configFile
367 	 *            to use, or {@code null} if none
368 	 * @param localUserName
369 	 *            user name of the current user on the local OS
370 	 * @return A {@link SshConfigStore}, or {@code null} if none is to be used
371 	 *
372 	 * @since 5.8
373 	 */
374 	protected SshConfigStore createSshConfigStore(@NonNull File homeDir,
375 			File configFile, String localUserName) {
376 		return configFile == null ? null
377 				: new OpenSshConfigFile(homeDir, configFile, localUserName);
378 	}
379 
380 	/**
381 	 * Obtains a {@link ServerKeyDatabase} to verify server host keys. The
382 	 * default implementation returns a {@link ServerKeyDatabase} that
383 	 * recognizes the two openssh standard files {@code ~/.ssh/known_hosts} and
384 	 * {@code ~/.ssh/known_hosts2} as well as any files configured via the
385 	 * {@code UserKnownHostsFile} option in the ssh config file.
386 	 *
387 	 * @param homeDir
388 	 *            home directory to use for ~ replacement
389 	 * @param sshDir
390 	 *            representing ~/.ssh/
391 	 * @return the {@link ServerKeyDatabase}
392 	 * @since 5.5
393 	 */
394 	@NonNull
395 	protected ServerKeyDatabase getServerKeyDatabase(@NonNull File homeDir,
396 			@NonNull File sshDir) {
397 		return defaultServerKeyDatabase.computeIfAbsent(
398 				new Tuple(new Object[] { homeDir, sshDir }),
399 				t -> createServerKeyDatabase(homeDir, sshDir));
400 
401 	}
402 
403 	/**
404 	 * Creates a {@link ServerKeyDatabase} to verify server host keys. The
405 	 * default implementation returns a {@link ServerKeyDatabase} that
406 	 * recognizes the two openssh standard files {@code ~/.ssh/known_hosts} and
407 	 * {@code ~/.ssh/known_hosts2} as well as any files configured via the
408 	 * {@code UserKnownHostsFile} option in the ssh config file.
409 	 *
410 	 * @param homeDir
411 	 *            home directory to use for ~ replacement
412 	 * @param sshDir
413 	 *            representing ~/.ssh/
414 	 * @return the {@link ServerKeyDatabase}
415 	 * @since 5.8
416 	 */
417 	@NonNull
418 	protected ServerKeyDatabase createServerKeyDatabase(@NonNull File homeDir,
419 			@NonNull File sshDir) {
420 		return new OpenSshServerKeyDatabase(true,
421 				getDefaultKnownHostsFiles(sshDir));
422 	}
423 
424 	/**
425 	 * Gets the list of default user known hosts files. The default returns
426 	 * ~/.ssh/known_hosts and ~/.ssh/known_hosts2. The ssh config
427 	 * {@code UserKnownHostsFile} overrides this default.
428 	 *
429 	 * @param sshDir
430 	 * @return the possibly empty list of default known host file paths.
431 	 */
432 	@NonNull
433 	protected List<Path> getDefaultKnownHostsFiles(@NonNull File sshDir) {
434 		return Arrays.asList(sshDir.toPath().resolve(SshConstants.KNOWN_HOSTS),
435 				sshDir.toPath().resolve(SshConstants.KNOWN_HOSTS + '2'));
436 	}
437 
438 	/**
439 	 * Determines the default keys. The default implementation will lazy load
440 	 * the {@link #getDefaultIdentities(File) default identity files}.
441 	 * <p>
442 	 * Subclasses may override and return an {@link Iterable} of whatever keys
443 	 * are appropriate. If the returned iterable lazily loads keys, it should be
444 	 * an instance of
445 	 * {@link org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider
446 	 * AbstractResourceKeyPairProvider} so that the session can later pass it
447 	 * the {@link #createKeyPasswordProvider(CredentialsProvider) password
448 	 * provider} wrapped as a {@link FilePasswordProvider} via
449 	 * {@link org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider#setPasswordFinder(FilePasswordProvider)
450 	 * AbstractResourceKeyPairProvider#setPasswordFinder(FilePasswordProvider)}
451 	 * so that encrypted, password-protected keys can be loaded.
452 	 * </p>
453 	 * <p>
454 	 * The default implementation uses exactly this mechanism; class
455 	 * {@link CachingKeyPairProvider} may serve as a model for a customized
456 	 * lazy-loading {@link Iterable} implementation
457 	 * </p>
458 	 * <p>
459 	 * If the {@link Iterable} returned has the keys already pre-loaded or
460 	 * otherwise doesn't need to decrypt encrypted keys, it can be any
461 	 * {@link Iterable}, for instance a simple {@link java.util.List List}.
462 	 * </p>
463 	 *
464 	 * @param sshDir
465 	 *            to look in for keys
466 	 * @return an {@link Iterable} over the default keys
467 	 * @since 5.3
468 	 */
469 	@NonNull
470 	protected Iterable<KeyPair> getDefaultKeys(@NonNull File sshDir) {
471 		List<Path> defaultIdentities = getDefaultIdentities(sshDir);
472 		return defaultKeys.computeIfAbsent(
473 				new Tuple(defaultIdentities.toArray(new Path[0])),
474 				t -> new CachingKeyPairProvider(defaultIdentities,
475 						getKeyCache()));
476 	}
477 
478 	/**
479 	 * Converts an {@link Iterable} of {link KeyPair}s into a
480 	 * {@link KeyIdentityProvider}.
481 	 *
482 	 * @param keys
483 	 *            to provide via the returned {@link KeyIdentityProvider}
484 	 * @return a {@link KeyIdentityProvider} that provides the given
485 	 *         {@code keys}
486 	 */
487 	private KeyIdentityProvider toKeyIdentityProvider(Iterable<KeyPair> keys) {
488 		if (keys instanceof KeyIdentityProvider) {
489 			return (KeyIdentityProvider) keys;
490 		}
491 		return (session) -> keys;
492 	}
493 
494 	/**
495 	 * Gets a list of default identities, i.e., private key files that shall
496 	 * always be tried for public key authentication. Typically those are
497 	 * ~/.ssh/id_dsa, ~/.ssh/id_rsa, and so on. The default implementation
498 	 * returns the files defined in {@link SshConstants#DEFAULT_IDENTITIES}.
499 	 *
500 	 * @param sshDir
501 	 *            the directory that represents ~/.ssh/
502 	 * @return a possibly empty list of paths containing default identities
503 	 *         (private keys)
504 	 */
505 	@NonNull
506 	protected List<Path> getDefaultIdentities(@NonNull File sshDir) {
507 		return Arrays
508 				.asList(SshConstants.DEFAULT_IDENTITIES).stream()
509 				.map(s -> new File(sshDir, s).toPath()).filter(Files::exists)
510 				.collect(Collectors.toList());
511 	}
512 
513 	/**
514 	 * Obtains the {@link KeyCache} to use to cache loaded keys.
515 	 *
516 	 * @return the {@link KeyCache}, or {@code null} if none.
517 	 */
518 	protected final KeyCache getKeyCache() {
519 		return keyCache;
520 	}
521 
522 	/**
523 	 * Creates a {@link KeyPasswordProvider} for a new session.
524 	 *
525 	 * @param provider
526 	 *            the {@link CredentialsProvider} to delegate to for user
527 	 *            interactions
528 	 * @return a new {@link KeyPasswordProvider}
529 	 */
530 	@NonNull
531 	protected KeyPasswordProvider createKeyPasswordProvider(
532 			CredentialsProvider provider) {
533 		return new IdentityPasswordProvider(provider);
534 	}
535 
536 	/**
537 	 * Creates a {@link FilePasswordProvider} for a new session.
538 	 *
539 	 * @param provider
540 	 *            the {@link KeyPasswordProvider} to delegate to
541 	 * @return a new {@link FilePasswordProvider}
542 	 */
543 	@NonNull
544 	private FilePasswordProvider createFilePasswordProvider(
545 			KeyPasswordProvider provider) {
546 		return new PasswordProviderWrapper(provider);
547 	}
548 
549 	/**
550 	 * Gets the user authentication mechanisms (or rather, factories for them).
551 	 * By default this returns gssapi-with-mic, public-key, password, and
552 	 * keyboard-interactive, in that order. The order is only significant if the
553 	 * ssh config does <em>not</em> set {@code PreferredAuthentications}; if it
554 	 * is set, the order defined there will be taken.
555 	 *
556 	 * @return the non-empty list of factories.
557 	 */
558 	@NonNull
559 	private List<UserAuthFactory> getUserAuthFactories() {
560 		// About the order of password and keyboard-interactive, see upstream
561 		// bug https://issues.apache.org/jira/projects/SSHD/issues/SSHD-866 .
562 		// Password auth doesn't have this problem.
563 		return Collections.unmodifiableList(
564 				Arrays.asList(GssApiWithMicAuthFactory.INSTANCE,
565 						UserAuthPublicKeyFactory.INSTANCE,
566 						JGitPasswordAuthFactory.INSTANCE,
567 						UserAuthKeyboardInteractiveFactory.INSTANCE));
568 	}
569 
570 	/**
571 	 * Gets the list of default preferred authentication mechanisms. If
572 	 * {@code null} is returned the openssh default list will be in effect. If
573 	 * the ssh config defines {@code PreferredAuthentications} the value from
574 	 * the ssh config takes precedence.
575 	 *
576 	 * @return a comma-separated list of mechanism names, or {@code null} if
577 	 *         none
578 	 */
579 	protected String getDefaultPreferredAuthentications() {
580 		return null;
581 	}
582 }