/* * Copyright (C) 2018, 2019 Thomas Wolf and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at * https://www.eclipse.org/org/documents/edl-v10.php. * * SPDX-License-Identifier: BSD-3-Clause */ package org.eclipse.jgit.transport.sshd; import java.io.Closeable; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyPair; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import org.apache.sshd.client.ClientBuilder; import org.apache.sshd.client.SshClient; import org.apache.sshd.client.auth.UserAuthFactory; import org.apache.sshd.client.auth.keyboard.UserAuthKeyboardInteractiveFactory; import org.apache.sshd.client.auth.pubkey.UserAuthPublicKeyFactory; import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; import org.apache.sshd.common.compression.BuiltinCompressions; import org.apache.sshd.common.config.keys.FilePasswordProvider; import org.apache.sshd.common.config.keys.loader.openssh.kdf.BCryptKdfOptions; import org.apache.sshd.common.keyprovider.KeyIdentityProvider; import org.eclipse.jgit.annotations.NonNull; import org.eclipse.jgit.errors.TransportException; import org.eclipse.jgit.internal.transport.sshd.CachingKeyPairProvider; import org.eclipse.jgit.internal.transport.sshd.GssApiWithMicAuthFactory; import org.eclipse.jgit.internal.transport.sshd.JGitPasswordAuthFactory; import org.eclipse.jgit.internal.transport.sshd.JGitServerKeyVerifier; import org.eclipse.jgit.internal.transport.sshd.JGitSshClient; import org.eclipse.jgit.internal.transport.sshd.JGitSshConfig; import org.eclipse.jgit.internal.transport.sshd.JGitUserInteraction; import org.eclipse.jgit.internal.transport.sshd.OpenSshServerKeyDatabase; import org.eclipse.jgit.internal.transport.sshd.PasswordProviderWrapper; import org.eclipse.jgit.internal.transport.sshd.SshdText; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.SshConstants; import org.eclipse.jgit.transport.SshSessionFactory; import org.eclipse.jgit.transport.URIish; import org.eclipse.jgit.util.FS; /** * A {@link SshSessionFactory} that uses Apache MINA sshd. Classes from Apache * MINA sshd are kept private to avoid API evolution problems when Apache MINA * sshd interfaces change. * * @since 5.2 */ public class SshdSessionFactory extends SshSessionFactory implements Closeable { private final AtomicBoolean closing = new AtomicBoolean(); private final Set sessions = new HashSet<>(); private final Map defaultHostConfigEntryResolver = new ConcurrentHashMap<>(); private final Map defaultServerKeyDatabase = new ConcurrentHashMap<>(); private final Map> defaultKeys = new ConcurrentHashMap<>(); private final KeyCache keyCache; private final ProxyDataFactory proxies; private File sshDirectory; private File homeDirectory; /** * Creates a new {@link SshdSessionFactory} without key cache and a * {@link DefaultProxyDataFactory}. */ public SshdSessionFactory() { this(null, new DefaultProxyDataFactory()); } /** * Creates a new {@link SshdSessionFactory} using the given {@link KeyCache} * and {@link ProxyDataFactory}. The {@code keyCache} is used for all sessions * created through this session factory; cached keys are destroyed when the * session factory is {@link #close() closed}. *

* Caching ssh keys in memory for an extended period of time is generally * considered bad practice, but there may be circumstances where using a * {@link KeyCache} is still the right choice, for instance to avoid that a * user gets prompted several times for the same password for the same key. * In general, however, it is preferable not to use a key cache but * to use a {@link #createKeyPasswordProvider(CredentialsProvider) * KeyPasswordProvider} that has access to some secure storage and can save * and retrieve passwords from there without user interaction. Another * approach is to use an ssh agent. *

*

* Note that the underlying ssh library (Apache MINA sshd) may or may not * keep ssh keys in memory for unspecified periods of time irrespective of * the use of a {@link KeyCache}. *

* * @param keyCache * {@link KeyCache} to use for caching ssh keys, or {@code null} * to not use a key cache * @param proxies * {@link ProxyDataFactory} to use, or {@code null} to not use a * proxy database (in which case connections through proxies will * not be possible) */ public SshdSessionFactory(KeyCache keyCache, ProxyDataFactory proxies) { super(); this.keyCache = keyCache; this.proxies = proxies; // sshd limits the number of BCrypt KDF rounds to 255 by default. // Decrypting such a key takes about two seconds on my machine. // I consider this limit too low. The time increases linearly with the // number of rounds. BCryptKdfOptions.setMaxAllowedRounds(16384); } /** A simple general map key. */ private static final class Tuple { private Object[] objects; public Tuple(Object[] objects) { this.objects = objects; } @Override public boolean equals(Object obj) { if (obj == this) { return true; } if (obj != null && obj.getClass() == Tuple.class) { Tuple other = (Tuple) obj; return Arrays.equals(objects, other.objects); } return false; } @Override public int hashCode() { return Arrays.hashCode(objects); } } // We can't really use a single client. Clients need to be stopped // properly, and we don't really know when to do that. Instead we use // a dedicated SshClient instance per session. We need a bit of caching to // avoid re-loading the ssh config and keys repeatedly. @Override public SshdSession getSession(URIish uri, CredentialsProvider credentialsProvider, FS fs, int tms) throws TransportException { SshdSession session = null; try { session = new SshdSession(uri, () -> { File home = getHomeDirectory(); if (home == null) { // Always use the detected filesystem for the user home! // It makes no sense to have different "user home" // directories depending on what file system a repository // is. home = FS.DETECTED.userHome(); } File sshDir = getSshDirectory(); if (sshDir == null) { sshDir = new File(home, SshConstants.SSH_DIR); } HostConfigEntryResolver configFile = getHostConfigEntryResolver( home, sshDir); KeyIdentityProvider defaultKeysProvider = toKeyIdentityProvider( getDefaultKeys(sshDir)); KeyPasswordProvider passphrases = createKeyPasswordProvider( credentialsProvider); SshClient client = ClientBuilder.builder() .factory(JGitSshClient::new) .filePasswordProvider( createFilePasswordProvider(passphrases)) .hostConfigEntryResolver(configFile) .serverKeyVerifier(new JGitServerKeyVerifier( getServerKeyDatabase(home, sshDir))) .compressionFactories( new ArrayList<>(BuiltinCompressions.VALUES)) .build(); client.setUserInteraction( new JGitUserInteraction(credentialsProvider)); client.setUserAuthFactories(getUserAuthFactories()); client.setKeyIdentityProvider(defaultKeysProvider); // JGit-specific things: JGitSshClient jgitClient = (JGitSshClient) client; jgitClient.setKeyCache(getKeyCache()); jgitClient.setCredentialsProvider(credentialsProvider); jgitClient.setProxyDatabase(proxies); String defaultAuths = getDefaultPreferredAuthentications(); if (defaultAuths != null) { jgitClient.setAttribute( JGitSshClient.PREFERRED_AUTHENTICATIONS, defaultAuths); } // Other things? return client; }); session.addCloseListener(s -> unregister(s)); register(session); session.connect(Duration.ofMillis(tms)); return session; } catch (Exception e) { unregister(session); throw new TransportException(uri, e.getMessage(), e); } } @Override public void close() { closing.set(true); boolean cleanKeys = false; synchronized (this) { cleanKeys = sessions.isEmpty(); } if (cleanKeys) { KeyCache cache = getKeyCache(); if (cache != null) { cache.close(); } } } private void register(SshdSession newSession) throws IOException { if (newSession == null) { return; } if (closing.get()) { throw new IOException(SshdText.get().sshClosingDown); } synchronized (this) { sessions.add(newSession); } } private void unregister(SshdSession oldSession) { boolean cleanKeys = false; synchronized (this) { sessions.remove(oldSession); cleanKeys = closing.get() && sessions.isEmpty(); } if (cleanKeys) { KeyCache cache = getKeyCache(); if (cache != null) { cache.close(); } } } /** * Set a global directory to use as the user's home directory * * @param homeDir * to use */ public void setHomeDirectory(@NonNull File homeDir) { if (homeDir.isAbsolute()) { homeDirectory = homeDir; } else { homeDirectory = homeDir.getAbsoluteFile(); } } /** * Retrieves the global user home directory * * @return the directory, or {@code null} if not set */ public File getHomeDirectory() { return homeDirectory; } /** * Set a global directory to use as the .ssh directory * * @param sshDir * to use */ public void setSshDirectory(@NonNull File sshDir) { if (sshDir.isAbsolute()) { sshDirectory = sshDir; } else { sshDirectory = sshDir.getAbsoluteFile(); } } /** * Retrieves the global .ssh directory * * @return the directory, or {@code null} if not set */ public File getSshDirectory() { return sshDirectory; } /** * Obtain a {@link HostConfigEntryResolver} to read the ssh config file and * to determine host entries for connections. * * @param homeDir * home directory to use for ~ replacement * @param sshDir * to use for looking for the config file * @return the resolver */ @NonNull private HostConfigEntryResolver getHostConfigEntryResolver( @NonNull File homeDir, @NonNull File sshDir) { return defaultHostConfigEntryResolver.computeIfAbsent( new Tuple(new Object[] { homeDir, sshDir }), t -> new JGitSshConfig(homeDir, getSshConfig(sshDir), getLocalUserName())); } /** * Determines the ssh config file. The default implementation returns * ~/.ssh/config. If the file does not exist and is created later it will be * picked up. To not use a config file at all, return {@code null}. * * @param sshDir * representing ~/.ssh/ * @return the file (need not exist), or {@code null} if no config file * shall be used * @since 5.5 */ protected File getSshConfig(@NonNull File sshDir) { return new File(sshDir, SshConstants.CONFIG); } /** * Obtain a {@link ServerKeyDatabase} to verify server host keys. The * default implementation returns a {@link ServerKeyDatabase} that * recognizes the two openssh standard files {@code ~/.ssh/known_hosts} and * {@code ~/.ssh/known_hosts2} as well as any files configured via the * {@code UserKnownHostsFile} option in the ssh config file. * * @param homeDir * home directory to use for ~ replacement * @param sshDir * representing ~/.ssh/ * @return the {@link ServerKeyDatabase} * @since 5.5 */ @NonNull protected ServerKeyDatabase getServerKeyDatabase(@NonNull File homeDir, @NonNull File sshDir) { return defaultServerKeyDatabase.computeIfAbsent( new Tuple(new Object[] { homeDir, sshDir }), t -> new OpenSshServerKeyDatabase(true, getDefaultKnownHostsFiles(sshDir))); } /** * Gets the list of default user known hosts files. The default returns * ~/.ssh/known_hosts and ~/.ssh/known_hosts2. The ssh config * {@code UserKnownHostsFile} overrides this default. * * @param sshDir * @return the possibly empty list of default known host file paths. */ @NonNull protected List getDefaultKnownHostsFiles(@NonNull File sshDir) { return Arrays.asList(sshDir.toPath().resolve(SshConstants.KNOWN_HOSTS), sshDir.toPath().resolve(SshConstants.KNOWN_HOSTS + '2')); } /** * Determines the default keys. The default implementation will lazy load * the {@link #getDefaultIdentities(File) default identity files}. *

* Subclasses may override and return an {@link Iterable} of whatever keys * are appropriate. If the returned iterable lazily loads keys, it should be * an instance of * {@link org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider * AbstractResourceKeyPairProvider} so that the session can later pass it * the {@link #createKeyPasswordProvider(CredentialsProvider) password * provider} wrapped as a {@link FilePasswordProvider} via * {@link org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider#setPasswordFinder(FilePasswordProvider) * AbstractResourceKeyPairProvider#setPasswordFinder(FilePasswordProvider)} * so that encrypted, password-protected keys can be loaded. *

*

* The default implementation uses exactly this mechanism; class * {@link CachingKeyPairProvider} may serve as a model for a customized * lazy-loading {@link Iterable} implementation *

*

* If the {@link Iterable} returned has the keys already pre-loaded or * otherwise doesn't need to decrypt encrypted keys, it can be any * {@link Iterable}, for instance a simple {@link java.util.List List}. *

* * @param sshDir * to look in for keys * @return an {@link Iterable} over the default keys * @since 5.3 */ @NonNull protected Iterable getDefaultKeys(@NonNull File sshDir) { List defaultIdentities = getDefaultIdentities(sshDir); return defaultKeys.computeIfAbsent( new Tuple(defaultIdentities.toArray(new Path[0])), t -> new CachingKeyPairProvider(defaultIdentities, getKeyCache())); } /** * Converts an {@link Iterable} of {link KeyPair}s into a * {@link KeyIdentityProvider}. * * @param keys * to provide via the returned {@link KeyIdentityProvider} * @return a {@link KeyIdentityProvider} that provides the given * {@code keys} */ private KeyIdentityProvider toKeyIdentityProvider(Iterable keys) { if (keys instanceof KeyIdentityProvider) { return (KeyIdentityProvider) keys; } return (session) -> keys; } /** * Gets a list of default identities, i.e., private key files that shall * always be tried for public key authentication. Typically those are * ~/.ssh/id_dsa, ~/.ssh/id_rsa, and so on. The default implementation * returns the files defined in {@link SshConstants#DEFAULT_IDENTITIES}. * * @param sshDir * the directory that represents ~/.ssh/ * @return a possibly empty list of paths containing default identities * (private keys) */ @NonNull protected List getDefaultIdentities(@NonNull File sshDir) { return Arrays .asList(SshConstants.DEFAULT_IDENTITIES).stream() .map(s -> new File(sshDir, s).toPath()).filter(Files::exists) .collect(Collectors.toList()); } /** * Obtains the {@link KeyCache} to use to cache loaded keys. * * @return the {@link KeyCache}, or {@code null} if none. */ protected final KeyCache getKeyCache() { return keyCache; } /** * Creates a {@link KeyPasswordProvider} for a new session. * * @param provider * the {@link CredentialsProvider} to delegate to for user * interactions * @return a new {@link KeyPasswordProvider} */ @NonNull protected KeyPasswordProvider createKeyPasswordProvider( CredentialsProvider provider) { return new IdentityPasswordProvider(provider); } /** * Creates a {@link FilePasswordProvider} for a new session. * * @param provider * the {@link KeyPasswordProvider} to delegate to * @return a new {@link FilePasswordProvider} */ @NonNull private FilePasswordProvider createFilePasswordProvider( KeyPasswordProvider provider) { return new PasswordProviderWrapper(provider); } /** * Gets the user authentication mechanisms (or rather, factories for them). * By default this returns gssapi-with-mic, public-key, password, and * keyboard-interactive, in that order. The order is only significant if the * ssh config does not set {@code PreferredAuthentications}; if it * is set, the order defined there will be taken. * * @return the non-empty list of factories. */ @NonNull private List getUserAuthFactories() { // About the order of password and keyboard-interactive, see upstream // bug https://issues.apache.org/jira/projects/SSHD/issues/SSHD-866 . // Password auth doesn't have this problem. return Collections.unmodifiableList( Arrays.asList(GssApiWithMicAuthFactory.INSTANCE, UserAuthPublicKeyFactory.INSTANCE, JGitPasswordAuthFactory.INSTANCE, UserAuthKeyboardInteractiveFactory.INSTANCE)); } /** * Gets the list of default preferred authentication mechanisms. If * {@code null} is returned the openssh default list will be in effect. If * the ssh config defines {@code PreferredAuthentications} the value from * the ssh config takes precedence. * * @return a comma-separated list of mechanism names, or {@code null} if * none */ protected String getDefaultPreferredAuthentications() { return null; } }