SshdSessionFactory.java
- /*
- * Copyright (C) 2018, 2020 Thomas Wolf <thomas.wolf@paranor.ch> 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.function.Supplier;
- 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.SshException;
- import org.apache.sshd.common.NamedFactory;
- 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.apache.sshd.common.signature.BuiltinSignatures;
- import org.apache.sshd.common.signature.Signature;
- import org.eclipse.jgit.annotations.NonNull;
- import org.eclipse.jgit.errors.TransportException;
- import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile;
- import org.eclipse.jgit.internal.transport.sshd.AuthenticationCanceledException;
- 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.SshConfigStore;
- 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 static final String MINA_SSHD = "mina-sshd"; //$NON-NLS-1$
- private final AtomicBoolean closing = new AtomicBoolean();
- private final Set<SshdSession> sessions = new HashSet<>();
- private final Map<Tuple, HostConfigEntryResolver> defaultHostConfigEntryResolver = new ConcurrentHashMap<>();
- private final Map<Tuple, ServerKeyDatabase> defaultServerKeyDatabase = new ConcurrentHashMap<>();
- private final Map<Tuple, Iterable<KeyPair>> 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}.
- * <p>
- * 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 <em>not</em> 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.
- * </p>
- * <p>
- * 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}.
- * </p>
- *
- * @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);
- }
- @Override
- public String getType() {
- return MINA_SSHD;
- }
- /** 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));
- SshClient client = ClientBuilder.builder()
- .factory(JGitSshClient::new)
- .filePasswordProvider(createFilePasswordProvider(
- () -> createKeyPasswordProvider(
- credentialsProvider)))
- .hostConfigEntryResolver(configFile)
- .serverKeyVerifier(new JGitServerKeyVerifier(
- getServerKeyDatabase(home, sshDir)))
- .signatureFactories(getSignatureFactories())
- .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);
- if (e instanceof TransportException) {
- throw (TransportException) e;
- }
- Throwable cause = e;
- if (e instanceof SshException && e
- .getCause() instanceof AuthenticationCanceledException) {
- // Results in a nicer error message
- cause = e.getCause();
- }
- throw new TransportException(uri, cause.getMessage(), cause);
- }
- }
- @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(createSshConfigStore(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);
- }
- /**
- * Obtains a {@link SshConfigStore}, or {@code null} if not SSH config is to
- * be used. The default implementation returns {@code null} if
- * {@code configFile == null} and otherwise an OpenSSH-compatible store
- * reading host entries from the given file.
- *
- * @param homeDir
- * may be used for ~-replacements by the returned config store
- * @param configFile
- * to use, or {@code null} if none
- * @param localUserName
- * user name of the current user on the local OS
- * @return A {@link SshConfigStore}, or {@code null} if none is to be used
- *
- * @since 5.8
- */
- protected SshConfigStore createSshConfigStore(@NonNull File homeDir,
- File configFile, String localUserName) {
- return configFile == null ? null
- : new OpenSshConfigFile(homeDir, configFile, localUserName);
- }
- /**
- * Obtains 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 -> createServerKeyDatabase(homeDir, sshDir));
- }
- /**
- * Creates 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.8
- */
- @NonNull
- protected ServerKeyDatabase createServerKeyDatabase(@NonNull File homeDir,
- @NonNull File sshDir) {
- return 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<Path> 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}.
- * <p>
- * 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.
- * </p>
- * <p>
- * The default implementation uses exactly this mechanism; class
- * {@link CachingKeyPairProvider} may serve as a model for a customized
- * lazy-loading {@link Iterable} implementation
- * </p>
- * <p>
- * 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}.
- * </p>
- *
- * @param sshDir
- * to look in for keys
- * @return an {@link Iterable} over the default keys
- * @since 5.3
- */
- @NonNull
- protected Iterable<KeyPair> getDefaultKeys(@NonNull File sshDir) {
- List<Path> 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<KeyPair> 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<Path> 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 providerFactory
- * providing the {@link KeyPasswordProvider} to delegate to
- * @return a new {@link FilePasswordProvider}
- */
- @NonNull
- private FilePasswordProvider createFilePasswordProvider(
- Supplier<KeyPasswordProvider> providerFactory) {
- return new PasswordProviderWrapper(providerFactory);
- }
- /**
- * 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 <em>not</em> set {@code PreferredAuthentications}; if it
- * is set, the order defined there will be taken.
- *
- * @return the non-empty list of factories.
- */
- @NonNull
- private List<UserAuthFactory> 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;
- }
- /**
- * Apache MINA sshd 2.6.0 has removed DSA, DSA_CERT and RSA_CERT. We have to
- * set it up explicitly to still allow users to connect with DSA keys.
- *
- * @return a list of supported signature factories
- */
- @SuppressWarnings("deprecation")
- private static List<NamedFactory<Signature>> getSignatureFactories() {
- // @formatter:off
- return Arrays.asList(
- BuiltinSignatures.nistp256_cert,
- BuiltinSignatures.nistp384_cert,
- BuiltinSignatures.nistp521_cert,
- BuiltinSignatures.ed25519_cert,
- BuiltinSignatures.rsaSHA512_cert,
- BuiltinSignatures.rsaSHA256_cert,
- BuiltinSignatures.rsa_cert,
- BuiltinSignatures.nistp256,
- BuiltinSignatures.nistp384,
- BuiltinSignatures.nistp521,
- BuiltinSignatures.ed25519,
- BuiltinSignatures.sk_ecdsa_sha2_nistp256,
- BuiltinSignatures.sk_ssh_ed25519,
- BuiltinSignatures.rsaSHA512,
- BuiltinSignatures.rsaSHA256,
- BuiltinSignatures.rsa,
- BuiltinSignatures.dsa_cert,
- BuiltinSignatures.dsa);
- // @formatter:on
- }
- }