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