diff options
Diffstat (limited to 'org.eclipse.jgit.ssh.apache/src/org/eclipse')
14 files changed, 1103 insertions, 142 deletions
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationLogger.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationLogger.java new file mode 100644 index 0000000000..add79b35c9 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationLogger.java @@ -0,0 +1,238 @@ +/* + * Copyright (C) 2022 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.internal.transport.sshd; + +import static org.eclipse.jgit.internal.transport.sshd.CachingKeyPairProvider.getKeyId; + +import java.security.KeyPair; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.sshd.client.auth.password.PasswordAuthenticationReporter; +import org.apache.sshd.client.auth.password.UserAuthPassword; +import org.apache.sshd.client.auth.pubkey.PublicKeyAuthenticationReporter; +import org.apache.sshd.client.auth.pubkey.UserAuthPublicKey; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.config.keys.KeyUtils; + +/** + * Provides a log of authentication attempts for a {@link ClientSession}. + */ +public class AuthenticationLogger { + + private final List<String> messages = new ArrayList<>(); + + // We're interested in this log only in the failure case, so we don't need + // to log authentication success. + + private final PublicKeyAuthenticationReporter pubkeyLogger = new PublicKeyAuthenticationReporter() { + + private boolean hasAttempts; + + @Override + public void signalAuthenticationAttempt(ClientSession session, + String service, KeyPair identity, String signature) + throws Exception { + hasAttempts = true; + String message; + if (identity.getPrivate() == null) { + // SSH agent key + message = MessageFormat.format( + SshdText.get().authPubkeyAttemptAgent, + UserAuthPublicKey.NAME, KeyUtils.getKeyType(identity), + getKeyId(session, identity), signature); + } else { + message = MessageFormat.format( + SshdText.get().authPubkeyAttempt, + UserAuthPublicKey.NAME, KeyUtils.getKeyType(identity), + getKeyId(session, identity), signature); + } + messages.add(message); + } + + @Override + public void signalAuthenticationExhausted(ClientSession session, + String service) throws Exception { + String message; + if (hasAttempts) { + message = MessageFormat.format( + SshdText.get().authPubkeyExhausted, + UserAuthPublicKey.NAME); + } else { + message = MessageFormat.format(SshdText.get().authPubkeyNoKeys, + UserAuthPublicKey.NAME); + } + messages.add(message); + hasAttempts = false; + } + + @Override + public void signalAuthenticationFailure(ClientSession session, + String service, KeyPair identity, boolean partial, + List<String> serverMethods) throws Exception { + String message; + if (partial) { + message = MessageFormat.format( + SshdText.get().authPubkeyPartialSuccess, + UserAuthPublicKey.NAME, KeyUtils.getKeyType(identity), + getKeyId(session, identity), serverMethods); + } else { + message = MessageFormat.format( + SshdText.get().authPubkeyFailure, + UserAuthPublicKey.NAME, KeyUtils.getKeyType(identity), + getKeyId(session, identity)); + } + messages.add(message); + } + }; + + private final PasswordAuthenticationReporter passwordLogger = new PasswordAuthenticationReporter() { + + private int attempts; + + @Override + public void signalAuthenticationAttempt(ClientSession session, + String service, String oldPassword, boolean modified, + String newPassword) throws Exception { + attempts++; + String message; + if (modified) { + message = MessageFormat.format( + SshdText.get().authPasswordChangeAttempt, + UserAuthPassword.NAME, Integer.valueOf(attempts)); + } else { + message = MessageFormat.format( + SshdText.get().authPasswordAttempt, + UserAuthPassword.NAME, Integer.valueOf(attempts)); + } + messages.add(message); + } + + @Override + public void signalAuthenticationExhausted(ClientSession session, + String service) throws Exception { + String message; + if (attempts > 0) { + message = MessageFormat.format( + SshdText.get().authPasswordExhausted, + UserAuthPassword.NAME); + } else { + message = MessageFormat.format( + SshdText.get().authPasswordNotTried, + UserAuthPassword.NAME); + } + messages.add(message); + attempts = 0; + } + + @Override + public void signalAuthenticationFailure(ClientSession session, + String service, String password, boolean partial, + List<String> serverMethods) throws Exception { + String message; + if (partial) { + message = MessageFormat.format( + SshdText.get().authPasswordPartialSuccess, + UserAuthPassword.NAME, serverMethods); + } else { + message = MessageFormat.format( + SshdText.get().authPasswordFailure, + UserAuthPassword.NAME); + } + messages.add(message); + } + }; + + private final GssApiWithMicAuthenticationReporter gssLogger = new GssApiWithMicAuthenticationReporter() { + + private boolean hasAttempts; + + @Override + public void signalAuthenticationAttempt(ClientSession session, + String service, String mechanism) { + hasAttempts = true; + String message = MessageFormat.format( + SshdText.get().authGssApiAttempt, + GssApiWithMicAuthFactory.NAME, mechanism); + messages.add(message); + } + + @Override + public void signalAuthenticationExhausted(ClientSession session, + String service) { + String message; + if (hasAttempts) { + message = MessageFormat.format( + SshdText.get().authGssApiExhausted, + GssApiWithMicAuthFactory.NAME); + } else { + message = MessageFormat.format( + SshdText.get().authGssApiNotTried, + GssApiWithMicAuthFactory.NAME); + } + messages.add(message); + hasAttempts = false; + } + + @Override + public void signalAuthenticationFailure(ClientSession session, + String service, String mechanism, boolean partial, + List<String> serverMethods) { + String message; + if (partial) { + message = MessageFormat.format( + SshdText.get().authGssApiPartialSuccess, + GssApiWithMicAuthFactory.NAME, mechanism, + serverMethods); + } else { + message = MessageFormat.format( + SshdText.get().authGssApiFailure, + GssApiWithMicAuthFactory.NAME, mechanism); + } + messages.add(message); + } + }; + + /** + * Creates a new {@link AuthenticationLogger} and configures the + * {@link ClientSession} to report authentication attempts through this + * instance. + * + * @param session + * to configure + */ + public AuthenticationLogger(ClientSession session) { + session.setPublicKeyAuthenticationReporter(pubkeyLogger); + session.setPasswordAuthenticationReporter(passwordLogger); + session.setAttribute( + GssApiWithMicAuthenticationReporter.GSS_AUTHENTICATION_REPORTER, + gssLogger); + // TODO: keyboard-interactive? sshd 2.8.0 has no callback + // interface for it. + } + + /** + * Retrieves the log messages for the authentication attempts. + * + * @return the messages as an unmodifiable list + */ + public List<String> getLog() { + return Collections.unmodifiableList(messages); + } + + /** + * Drops all previously recorded log messages. + */ + public void clear() { + messages.clear(); + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java index 79b3637caa..cbd6a64140 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2022 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 @@ -11,6 +11,7 @@ package org.eclipse.jgit.internal.transport.sshd; import static java.text.MessageFormat.format; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; @@ -19,18 +20,24 @@ import java.security.GeneralSecurityException; import java.security.InvalidKeyException; import java.security.KeyPair; import java.security.PrivateKey; +import java.security.PublicKey; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; import java.util.concurrent.CancellationException; import javax.security.auth.DestroyFailedException; +import org.apache.sshd.common.AttributeRepository.AttributeKey; +import org.apache.sshd.client.session.ClientSession; import org.apache.sshd.common.NamedResource; import org.apache.sshd.common.config.keys.FilePasswordProvider; +import org.apache.sshd.common.config.keys.KeyUtils; import org.apache.sshd.common.keyprovider.FileKeyPairProvider; import org.apache.sshd.common.session.SessionContext; import org.apache.sshd.common.util.io.resource.IoResource; @@ -43,6 +50,14 @@ import org.eclipse.jgit.transport.sshd.KeyCache; public class CachingKeyPairProvider extends FileKeyPairProvider implements Iterable<KeyPair> { + /** + * An attribute set on the {@link SessionContext} recording loaded keys by + * fingerprint. This enables us to provide nicer output by showing key + * paths, if possible. Users can identify key identities used easier by + * filename than by fingerprint. + */ + public static final AttributeKey<Map<String, Path>> KEY_PATHS_BY_FINGERPRINT = new AttributeKey<>(); + private final KeyCache cache; /** @@ -78,6 +93,33 @@ public class CachingKeyPairProvider extends FileKeyPairProvider return () -> iterator(session); } + static String getKeyId(ClientSession session, KeyPair identity) { + String fingerprint = KeyUtils.getFingerPrint(identity.getPublic()); + Map<String, Path> registered = session + .getAttribute(KEY_PATHS_BY_FINGERPRINT); + if (registered != null) { + Path path = registered.get(fingerprint); + if (path != null) { + Path home = session + .resolveAttribute(JGitSshClient.HOME_DIRECTORY); + if (home != null && path.startsWith(home)) { + try { + path = home.relativize(path); + String pathString = path.toString(); + if (!pathString.isEmpty()) { + return "~" + File.separator + pathString; //$NON-NLS-1$ + } + } catch (IllegalArgumentException e) { + // Cannot be relativized. Ignore, and work with the + // original path + } + } + return path.toString(); + } + } + return fingerprint; + } + private KeyPair loadKey(SessionContext session, Path path) throws IOException, GeneralSecurityException { if (!Files.exists(path)) { @@ -123,13 +165,23 @@ public class CachingKeyPairProvider extends FileKeyPairProvider SshdText.get().identityFileUnsupportedFormat, path)); } KeyPair result = keys.next(); + PublicKey pk = result.getPublic(); + if (pk != null) { + Map<String, Path> registered = session + .getAttribute(KEY_PATHS_BY_FINGERPRINT); + if (registered == null) { + registered = new HashMap<>(); + session.setAttribute(KEY_PATHS_BY_FINGERPRINT, registered); + } + registered.put(KeyUtils.getFingerPrint(pk), path); + } if (keys.hasNext()) { log.warn(format(SshdText.get().identityFileMultipleKeys, path)); keys.forEachRemaining(k -> { - PrivateKey pk = k.getPrivate(); - if (pk != null) { + PrivateKey priv = k.getPrivate(); + if (priv != null) { try { - pk.destroy(); + priv.destroy(); } catch (DestroyFailedException e) { // Ignore } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java index c3cac0c1df..df01db316b 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2022 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 @@ -18,6 +18,7 @@ import java.net.SocketAddress; import java.net.UnknownHostException; import java.util.Collection; import java.util.Iterator; +import java.util.List; import org.apache.sshd.client.auth.AbstractUserAuth; import org.apache.sshd.client.session.ClientSession; @@ -71,7 +72,10 @@ public class GssApiWithMicAuthentication extends AbstractUserAuth { if (context != null) { close(false); } + GssApiWithMicAuthenticationReporter reporter = session.getAttribute( + GssApiWithMicAuthenticationReporter.GSS_AUTHENTICATION_REPORTER); if (!nextMechanism.hasNext()) { + reporter.signalAuthenticationExhausted(session, service); return false; } state = ProtocolState.STARTED; @@ -79,6 +83,7 @@ public class GssApiWithMicAuthentication extends AbstractUserAuth { // RFC 4462 states that SPNEGO must not be used with ssh while (GssApiMechanisms.SPNEGO.equals(currentMechanism)) { if (!nextMechanism.hasNext()) { + reporter.signalAuthenticationExhausted(session, service); return false; } currentMechanism = nextMechanism.next(); @@ -102,6 +107,10 @@ public class GssApiWithMicAuthentication extends AbstractUserAuth { state = ProtocolState.FAILED; return false; } + if (reporter != null) { + reporter.signalAuthenticationAttempt(session, service, + currentMechanism.toString()); + } Buffer buffer = session .createBuffer(SshConstants.SSH_MSG_USERAUTH_REQUEST); buffer.putString(session.getUsername()); @@ -246,4 +255,26 @@ public class GssApiWithMicAuthentication extends AbstractUserAuth { return false; } + @Override + public void signalAuthMethodSuccess(ClientSession session, String service, + Buffer buffer) throws Exception { + GssApiWithMicAuthenticationReporter reporter = session.getAttribute( + GssApiWithMicAuthenticationReporter.GSS_AUTHENTICATION_REPORTER); + if (reporter != null) { + reporter.signalAuthenticationSuccess(session, service, + currentMechanism.toString()); + } + } + + @Override + public void signalAuthMethodFailure(ClientSession session, String service, + boolean partial, List<String> serverMethods, Buffer buffer) + throws Exception { + GssApiWithMicAuthenticationReporter reporter = session.getAttribute( + GssApiWithMicAuthenticationReporter.GSS_AUTHENTICATION_REPORTER); + if (reporter != null) { + reporter.signalAuthenticationFailure(session, service, + currentMechanism.toString(), partial, serverMethods); + } + } } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthenticationReporter.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthenticationReporter.java new file mode 100644 index 0000000000..201a131650 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthenticationReporter.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2022 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.internal.transport.sshd; + +import java.util.List; + +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.AttributeRepository.AttributeKey; + +/** + * Callback interface for recording authentication state in + * {@link GssApiWithMicAuthentication}. + */ +public interface GssApiWithMicAuthenticationReporter { + + /** + * An {@link AttributeKey} for a {@link ClientSession} holding the + * {@link GssApiWithMicAuthenticationReporter}. + */ + static final AttributeKey<GssApiWithMicAuthenticationReporter> GSS_AUTHENTICATION_REPORTER = new AttributeKey<>(); + + /** + * Called when a new authentication attempt is made. + * + * @param session + * the {@link ClientSession} + * @param service + * the name of the requesting SSH service name + * @param mechanism + * the OID of the mechanism used + */ + default void signalAuthenticationAttempt(ClientSession session, + String service, String mechanism) { + // nothing + } + + /** + * Called when there are no more mechanisms to try. + * + * @param session + * the {@link ClientSession} + * @param service + * the name of the requesting SSH service name + */ + default void signalAuthenticationExhausted(ClientSession session, + String service) { + // nothing + } + + /** + * Called when authentication was succeessful. + * + * @param session + * the {@link ClientSession} + * @param service + * the name of the requesting SSH service name + * @param mechanism + * the OID of the mechanism used + */ + default void signalAuthenticationSuccess(ClientSession session, + String service, String mechanism) { + // nothing + } + + /** + * Called when the authentication was not successful. + * + * @param session + * the {@link ClientSession} + * @param service + * the name of the requesting SSH service name + * @param mechanism + * the OID of the mechanism used + * @param partial + * {@code true} if authentication was partially successful, + * meaning one continues with additional authentication methods + * given by {@code serverMethods} + * @param serverMethods + * the {@link List} of authentication methods that can continue + */ + default void signalAuthenticationFailure(ClientSession session, + String service, String mechanism, boolean partial, + List<String> serverMethods) { + // nothing + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java index e2dbb4c466..5100bc9e54 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java @@ -42,7 +42,6 @@ import org.apache.sshd.common.io.IoSession; import org.apache.sshd.common.io.IoWriteFuture; import org.apache.sshd.common.kex.BuiltinDHFactories; import org.apache.sshd.common.kex.DHFactory; -import org.apache.sshd.common.kex.KexProposalOption; import org.apache.sshd.common.kex.KeyExchangeFactory; import org.apache.sshd.common.kex.extension.KexExtensionHandler; import org.apache.sshd.common.kex.extension.KexExtensionHandler.AvailabilityPhase; @@ -199,24 +198,6 @@ public class JGitClientSession extends ClientSessionImpl { } } - @Override - protected Map<KexProposalOption, String> setNegotiationResult( - Map<KexProposalOption, String> guess) { - Map<KexProposalOption, String> result = super.setNegotiationResult( - guess); - // This should be doable with a SessionListener, too, but I don't see - // how to add a listener in time to catch the negotiation end for sure - // given that the super-constructor already starts KEX. - // - // TODO: This override can be removed once we use sshd 2.8.0. - if (log.isDebugEnabled()) { - result.forEach((option, value) -> log.debug( - "setNegotiationResult({}) Kex: {} = {}", this, //$NON-NLS-1$ - option.getDescription(), value)); - } - return result; - } - Set<String> getAllAvailableSignatureAlgorithms() { Set<String> allAvailable = new HashSet<>(); BuiltinSignatures.VALUES.forEach(s -> allAvailable.add(s.getName())); diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPasswordAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPasswordAuthentication.java index ff8caaacc0..33c3c608f6 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPasswordAuthentication.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPasswordAuthentication.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2022 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 @@ -11,13 +11,11 @@ package org.eclipse.jgit.internal.transport.sshd; import static org.apache.sshd.core.CoreModuleProperties.PASSWORD_PROMPTS; -import org.apache.sshd.client.auth.keyboard.UserInteraction; import org.apache.sshd.client.auth.password.UserAuthPassword; import org.apache.sshd.client.session.ClientSession; /** - * A password authentication handler that uses the {@link JGitUserInteraction} - * to ask the user for the password. It also respects the + * A password authentication handler that respects the * {@code NumberOfPasswordPrompts} ssh config. */ public class JGitPasswordAuthentication extends UserAuthPassword { @@ -35,30 +33,11 @@ public class JGitPasswordAuthentication extends UserAuthPassword { } @Override - protected boolean sendAuthDataRequest(ClientSession session, String service) - throws Exception { + protected String resolveAttemptedPassword(ClientSession session, + String service) throws Exception { if (++attempts > maxAttempts) { - return false; + return null; } - UserInteraction interaction = session.getUserInteraction(); - if (!interaction.isInteractionAllowed(session)) { - return false; - } - String password = getPassword(session, interaction); - if (password == null) { - throw new AuthenticationCanceledException(); - } - // sendPassword takes a buffer as first argument, but actually doesn't - // use it and creates its own buffer... - sendPassword(null, session, password, password); - return true; - } - - private String getPassword(ClientSession session, - UserInteraction interaction) { - String[] results = interaction.interactive(session, null, null, "", //$NON-NLS-1$ - new String[] { SshdText.get().passwordPrompt }, - new boolean[] { false }); - return (results == null || results.length == 0) ? null : results[0]; + return super.resolveAttemptedPassword(session, service); } } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java index c082a9a963..e1036c6283 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2022 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 @@ -12,25 +12,68 @@ package org.eclipse.jgit.internal.transport.sshd; import static java.text.MessageFormat.format; import static org.eclipse.jgit.transport.SshConstants.PUBKEY_ACCEPTED_ALGORITHMS; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.PublicKey; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.stream.Collectors; +import org.apache.sshd.agent.SshAgent; +import org.apache.sshd.agent.SshAgentFactory; +import org.apache.sshd.agent.SshAgentKeyConstraint; import org.apache.sshd.client.auth.pubkey.KeyAgentIdentity; import org.apache.sshd.client.auth.pubkey.PublicKeyIdentity; import org.apache.sshd.client.auth.pubkey.UserAuthPublicKey; +import org.apache.sshd.client.auth.pubkey.UserAuthPublicKeyIterator; import org.apache.sshd.client.config.hosts.HostConfigEntry; import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.FactoryManager; import org.apache.sshd.common.NamedFactory; +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; +import org.apache.sshd.common.config.keys.u2f.SecurityKeyPublicKey; import org.apache.sshd.common.signature.Signature; +import org.apache.sshd.common.signature.SignatureFactoriesManager; +import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile; +import org.eclipse.jgit.transport.CredentialItem; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.SshConstants; +import org.eclipse.jgit.transport.URIish; import org.eclipse.jgit.util.StringUtils; /** * Custom {@link UserAuthPublicKey} implementation for handling SSH config - * PubkeyAcceptedAlgorithms. + * PubkeyAcceptedAlgorithms and interaction with the SSH agent. */ public class JGitPublicKeyAuthentication extends UserAuthPublicKey { + private SshAgent agent; + + private HostConfigEntry hostConfig; + + private boolean addKeysToAgent; + + private boolean askBeforeAdding; + + private String skProvider; + + private SshAgentKeyConstraint[] constraints; + JGitPublicKeyAuthentication(List<NamedFactory<Signature>> factories) { super(factories); } @@ -43,7 +86,7 @@ public class JGitPublicKeyAuthentication extends UserAuthPublicKey { + rawSession.getClass().getCanonicalName()); } JGitClientSession session = (JGitClientSession) rawSession; - HostConfigEntry hostConfig = session.getHostConfigEntry(); + hostConfig = session.getHostConfigEntry(); // Set signature algorithms for public key authentication String pubkeyAlgos = hostConfig.getProperty(PUBKEY_ACCEPTED_ALGORITHMS); if (!StringUtils.isEmptyOrNull(pubkeyAlgos)) { @@ -56,54 +99,304 @@ public class JGitPublicKeyAuthentication extends UserAuthPublicKey { log.debug(PUBKEY_ACCEPTED_ALGORITHMS + ' ' + signatures); } setSignatureFactoriesNames(signatures); - } else { - log.warn(format(SshdText.get().configNoKnownAlgorithms, - PUBKEY_ACCEPTED_ALGORITHMS, pubkeyAlgos)); + super.init(session, service); + return; } + log.warn(format(SshdText.get().configNoKnownAlgorithms, + PUBKEY_ACCEPTED_ALGORITHMS, pubkeyAlgos)); + } + // TODO: remove this once we're on an sshd version that has SSHD-1272 + // fixed + List<NamedFactory<Signature>> localFactories = getSignatureFactories(); + if (localFactories == null || localFactories.isEmpty()) { + setSignatureFactoriesNames(session.getSignatureFactoriesNames()); } - // If we don't set signature factories here, the default ones from the - // session will be used. super.init(session, service); - // In sshd 2.7.0, we end up now with a key iterator that uses keys - // provided by an ssh-agent even if IdentitiesOnly is true. So if - // needed, filter out any KeyAgentIdentity. - if (hostConfig.isIdentitiesOnly()) { - Iterator<PublicKeyIdentity> original = keys; - // The original iterator will already have gotten the identities - // from the agent. Unfortunately there's nothing we can do about - // that; it'll have to be fixed upstream. (As will, ultimately, - // respecting isIdentitiesOnly().) At least we can simply not - // use the keys the agent provided. - // - // See https://issues.apache.org/jira/browse/SSHD-1218 - keys = new Iterator<>() { - - private PublicKeyIdentity value; + } + + @Override + protected Iterator<PublicKeyIdentity> createPublicKeyIterator( + ClientSession session, SignatureFactoriesManager manager) + throws Exception { + agent = getAgent(session); + if (agent != null) { + parseAddKeys(hostConfig); + if (addKeysToAgent) { + skProvider = hostConfig.getProperty(SshConstants.SECURITY_KEY_PROVIDER); + } + } + return new KeyIterator(session, manager); + } + + @Override + protected PublicKeyIdentity resolveAttemptedPublicKeyIdentity( + ClientSession session, String service) throws Exception { + PublicKeyIdentity result = getNextKey(session, service); + // This fixes SSHD-1231. Can be removed once we're using Apache MINA + // sshd > 2.8.0. + // + // See https://issues.apache.org/jira/browse/SSHD-1231 + currentAlgorithms.clear(); + return result; + } + + private PublicKeyIdentity getNextKey(ClientSession session, String service) + throws Exception { + PublicKeyIdentity id = super.resolveAttemptedPublicKeyIdentity(session, + service); + if (addKeysToAgent && id != null && !(id instanceof KeyAgentIdentity)) { + KeyPair key = id.getKeyIdentity(); + if (key != null && key.getPublic() != null + && key.getPrivate() != null) { + // We've just successfully loaded a key that wasn't in the + // agent. Add it to the agent. + // + // Keys are added after loading, as in OpenSSH. The alternative + // might be to add a key only after (partially) successful + // authentication? + PublicKey pk = key.getPublic(); + String fingerprint = KeyUtils.getFingerPrint(pk); + String keyType = KeyUtils.getKeyType(key); + try { + // Check that the key is not in the agent already. + if (agentHasKey(pk)) { + return id; + } + if (askBeforeAdding + && (session instanceof JGitClientSession)) { + CredentialsProvider provider = ((JGitClientSession) session) + .getCredentialsProvider(); + CredentialItem.YesNoType question = new CredentialItem.YesNoType( + format(SshdText + .get().pubkeyAuthAddKeyToAgentQuestion, + keyType, fingerprint)); + boolean result = provider != null + && provider.supports(question) + && provider.get(getUri(), question); + if (!result || !question.getValue()) { + // Don't add the key. + return id; + } + } + SshAgentKeyConstraint[] rules = constraints; + if (pk instanceof SecurityKeyPublicKey && !StringUtils.isEmptyOrNull(skProvider)) { + rules = Arrays.copyOf(rules, rules.length + 1); + rules[rules.length - 1] = + new SshAgentKeyConstraint.FidoProviderExtension(skProvider); + } + // Unfortunately a comment associated with the key is lost + // by Apache MINA sshd, and there is also no way to get the + // original file name for keys loaded from a file. So add it + // without comment. + agent.addIdentity(key, null, rules); + } catch (IOException e) { + // Do not re-throw: we don't want authentication to fail if + // we cannot add the key to the agent. + log.error( + format(SshdText.get().pubkeyAuthAddKeyToAgentError, + keyType, fingerprint), + e); + // Note that as of Win32-OpenSSH 8.6 and Pageant 0.76, + // neither can handle key constraints. Pageant fails + // gracefully, not adding the key and returning + // SSH_AGENT_FAILURE. Win32-OpenSSH closes the connection + // without even returning a failure message, which violates + // the SSH agent protocol and makes all subsequent requests + // to the agent fail. + } + } + } + return id; + } + + private boolean agentHasKey(PublicKey pk) throws IOException { + Iterable<? extends Map.Entry<PublicKey, String>> ids = agent + .getIdentities(); + if (ids == null) { + return false; + } + Iterator<? extends Map.Entry<PublicKey, String>> iter = ids.iterator(); + while (iter.hasNext()) { + if (KeyUtils.compareKeys(iter.next().getKey(), pk)) { + return true; + } + } + return false; + } + + private URIish getUri() { + String uri = SshConstants.SSH_SCHEME + "://"; //$NON-NLS-1$ + String userName = hostConfig.getUsername(); + if (!StringUtils.isEmptyOrNull(userName)) { + uri += userName + '@'; + } + uri += hostConfig.getHost(); + int port = hostConfig.getPort(); + if (port > 0 && port != SshConstants.SSH_DEFAULT_PORT) { + uri += ":" + port; //$NON-NLS-1$ + } + try { + return new URIish(uri); + } catch (URISyntaxException e) { + log.error(e.getLocalizedMessage(), e); + } + return new URIish(); + } + + private SshAgent getAgent(ClientSession session) throws Exception { + FactoryManager manager = Objects.requireNonNull( + session.getFactoryManager(), "No session factory manager"); //$NON-NLS-1$ + SshAgentFactory factory = manager.getAgentFactory(); + if (factory == null) { + return null; + } + return factory.createClient(session, manager); + } + + private void parseAddKeys(HostConfigEntry config) { + String value = config.getProperty(SshConstants.ADD_KEYS_TO_AGENT); + if (StringUtils.isEmptyOrNull(value)) { + addKeysToAgent = false; + return; + } + String[] values = value.split(","); //$NON-NLS-1$ + List<SshAgentKeyConstraint> rules = new ArrayList<>(2); + switch (values[0]) { + case "yes": //$NON-NLS-1$ + addKeysToAgent = true; + break; + case "no": //$NON-NLS-1$ + addKeysToAgent = false; + break; + case "ask": //$NON-NLS-1$ + addKeysToAgent = true; + askBeforeAdding = true; + break; + case "confirm": //$NON-NLS-1$ + addKeysToAgent = true; + rules.add(SshAgentKeyConstraint.CONFIRM); + if (values.length > 1) { + int seconds = OpenSshConfigFile.timeSpec(values[1]); + if (seconds > 0) { + rules.add(new SshAgentKeyConstraint.LifeTime(seconds)); + } + } + break; + default: + int seconds = OpenSshConfigFile.timeSpec(values[0]); + if (seconds > 0) { + addKeysToAgent = true; + rules.add(new SshAgentKeyConstraint.LifeTime(seconds)); + } + break; + } + constraints = rules.toArray(new SshAgentKeyConstraint[0]); + } + + @Override + protected void releaseKeys() throws IOException { + addKeysToAgent = false; + askBeforeAdding = false; + skProvider = null; + constraints = null; + try { + if (agent != null) { + try { + agent.close(); + } finally { + agent = null; + } + } + } finally { + super.releaseKeys(); + } + } + + private class KeyIterator extends UserAuthPublicKeyIterator { + + private Iterable<? extends Map.Entry<PublicKey, String>> agentKeys; + + // If non-null, all the public keys from explicitly given key files. Any + // agent key not matching one of these public keys will be ignored in + // getIdentities(). + private Collection<PublicKey> identityFiles; + + public KeyIterator(ClientSession session, + SignatureFactoriesManager manager) + throws Exception { + super(session, manager); + } + + private List<PublicKey> getExplicitKeys( + Collection<String> explicitFiles) { + if (explicitFiles == null) { + return null; + } + return explicitFiles.stream().map(s -> { + try { + Path p = Paths.get(s + ".pub"); //$NON-NLS-1$ + if (Files.isRegularFile(p, LinkOption.NOFOLLOW_LINKS)) { + return AuthorizedKeyEntry.readAuthorizedKeys(p).get(0) + .resolvePublicKey(null, + PublicKeyEntryResolver.IGNORING); + } + } catch (InvalidPathException | IOException + | GeneralSecurityException e) { + log.warn(format(SshdText.get().cannotReadPublicKey, s), e); + } + return null; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + @Override + protected Iterable<KeyAgentIdentity> initializeAgentIdentities( + ClientSession session) throws IOException { + if (agent == null) { + return null; + } + agentKeys = agent.getIdentities(); + if (hostConfig != null && hostConfig.isIdentitiesOnly()) { + identityFiles = getExplicitKeys(hostConfig.getIdentities()); + } + return () -> new Iterator<>() { + + private final Iterator<? extends Map.Entry<PublicKey, String>> iter = agentKeys + .iterator(); + + private Map.Entry<PublicKey, String> next; @Override public boolean hasNext() { - if (value != null) { - return true; - } - PublicKeyIdentity next = null; - while (original.hasNext()) { - next = original.next(); - if (!(next instanceof KeyAgentIdentity)) { - value = next; + while (next == null && iter.hasNext()) { + Map.Entry<PublicKey, String> val = iter.next(); + PublicKey pk = val.getKey(); + // This checks against all explicit keys for any agent + // key, but since identityFiles.size() is typically 1, + // it should be fine. + if (identityFiles == null || identityFiles.stream() + .anyMatch(k -> KeyUtils.compareKeys(k, pk))) { + next = val; return true; } + if (log.isTraceEnabled()) { + log.trace( + "Ignoring SSH agent {} key not in explicit IdentityFile in SSH config: {}", //$NON-NLS-1$ + KeyUtils.getKeyType(pk), + KeyUtils.getFingerPrint(pk)); + } } - return false; + return next != null; } @Override - public PublicKeyIdentity next() { - if (hasNext()) { - PublicKeyIdentity result = value; - value = null; - return result; + public KeyAgentIdentity next() { + if (!hasNext()) { + throw new NoSuchElementException(); } - throw new NoSuchElementException(); + KeyAgentIdentity result = new KeyAgentIdentity(agent, + next.getKey(), next.getValue()); + next = null; + return result; } }; } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java index 71e8e61585..72f0bdb6ee 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2022 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 @@ -87,6 +87,11 @@ public class JGitSshClient extends SshClient { public static final AttributeKey<String> PREFERRED_AUTHENTICATIONS = new AttributeKey<>(); /** + * An attribute key for the home directory. + */ + public static final AttributeKey<Path> HOME_DIRECTORY = new AttributeKey<>(); + + /** * An attribute key for storing an alternate local address to connect to if * a local forward from a ProxyJump ssh config is present. If set, * {@link #connect(HostConfigEntry, AttributeRepository, SocketAddress)} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java index c51a75bc6f..2a725ea16a 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2022 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 @@ -120,15 +120,16 @@ public class JGitUserInteraction implements UserInteraction { return null; }).filter(s -> s != null).toArray(String[]::new); } - // TODO What to throw to abort the connection/authentication process? - // In UserAuthKeyboardInteractive.getUserResponses() it's clear that - // returning null is valid and signifies "an error"; we'll try the - // next authentication method. But if the user explicitly canceled, - // then we don't want to try the next methods... - // - // Probably not a serious issue with the typical order of public-key, - // keyboard-interactive, password. - return null; + throw new AuthenticationCanceledException(); + } + + @Override + public String resolveAuthPasswordAttempt(ClientSession session) + throws Exception { + String[] results = interactive(session, null, null, "", //$NON-NLS-1$ + new String[] { SshdText.get().passwordPrompt }, + new boolean[] { false }); + return (results == null || results.length == 0) ? null : results[0]; } @Override diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java index 00ee62d6dd..39332d9fca 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2022 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 @@ -29,7 +29,25 @@ public final class SshdText extends TranslationBundle { // @formatter:off /***/ public String authenticationCanceled; /***/ public String authenticationOnClosedSession; + /***/ public String authGssApiAttempt; + /***/ public String authGssApiExhausted; + /***/ public String authGssApiFailure; + /***/ public String authGssApiNotTried; + /***/ public String authGssApiPartialSuccess; + /***/ public String authPasswordAttempt; + /***/ public String authPasswordChangeAttempt; + /***/ public String authPasswordExhausted; + /***/ public String authPasswordFailure; + /***/ public String authPasswordNotTried; + /***/ public String authPasswordPartialSuccess; + /***/ public String authPubkeyAttempt; + /***/ public String authPubkeyAttemptAgent; + /***/ public String authPubkeyExhausted; + /***/ public String authPubkeyFailure; + /***/ public String authPubkeyNoKeys; + /***/ public String authPubkeyPartialSuccess; /***/ public String closeListenerFailed; + /***/ public String cannotReadPublicKey; /***/ public String configInvalidPath; /***/ public String configInvalidPattern; /***/ public String configInvalidPositive; @@ -98,6 +116,8 @@ public final class SshdText extends TranslationBundle { /***/ public String proxySocksUnexpectedMessage; /***/ public String proxySocksUnexpectedVersion; /***/ public String proxySocksUsernameTooLong; + /***/ public String pubkeyAuthAddKeyToAgentError; + /***/ public String pubkeyAuthAddKeyToAgentQuestion; /***/ public String pubkeyAuthWrongCommand; /***/ public String pubkeyAuthWrongKey; /***/ public String pubkeyAuthWrongSignatureAlgorithm; @@ -106,9 +126,13 @@ public final class SshdText extends TranslationBundle { /***/ public String serverIdWithNul; /***/ public String sessionCloseFailed; /***/ public String sessionWithoutUsername; + /***/ public String sshAgentEdDSAFormatError; + /***/ public String sshAgentPayloadLengthError; /***/ public String sshAgentReplyLengthError; /***/ public String sshAgentReplyUnexpected; /***/ public String sshAgentShortReadBuffer; + /***/ public String sshAgentUnknownKey; + /***/ public String sshAgentWrongKeyLength; /***/ public String sshAgentWrongNumberOfKeys; /***/ public String sshClosingDown; /***/ public String sshCommandTimeout; diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/JGitSshAgentFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/JGitSshAgentFactory.java index 1ed2ab9d78..a0ffd540f2 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/JGitSshAgentFactory.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/JGitSshAgentFactory.java @@ -17,10 +17,14 @@ import java.util.List; import org.apache.sshd.agent.SshAgent; import org.apache.sshd.agent.SshAgentFactory; import org.apache.sshd.agent.SshAgentServer; +import org.apache.sshd.client.config.hosts.HostConfigEntry; import org.apache.sshd.common.FactoryManager; import org.apache.sshd.common.channel.ChannelFactory; import org.apache.sshd.common.session.ConnectionService; +import org.apache.sshd.common.session.Session; import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.internal.transport.sshd.JGitClientSession; +import org.eclipse.jgit.transport.SshConstants; import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory; /** @@ -49,18 +53,24 @@ public class JGitSshAgentFactory implements SshAgentFactory { @Override public List<ChannelFactory> getChannelForwardingFactories( FactoryManager manager) { - // No agent forwarding supported yet. + // No agent forwarding supported. return Collections.emptyList(); } @Override - public SshAgent createClient(FactoryManager manager) throws IOException { - // sshd 2.8.0 will pass us the session here. At that point, we can get - // the HostConfigEntry and extract and handle the IdentityAgent setting. - // For now, pass null to let the ConnectorFactory do its default - // behavior (Pageant on Windows, SSH_AUTH_SOCK on Unixes with the - // jgit-builtin factory). - return new SshAgentClient(factory.create(null, homeDir)); + public SshAgent createClient(Session session, FactoryManager manager) + throws IOException { + String identityAgent = null; + if (session instanceof JGitClientSession) { + HostConfigEntry hostConfig = ((JGitClientSession) session) + .getHostConfigEntry(); + identityAgent = hostConfig.getProperty(SshConstants.IDENTITY_AGENT, + null); + } + if (SshConstants.NONE.equals(identityAgent)) { + return null; + } + return new SshAgentClient(factory.create(identityAgent, homeDir)); } @Override diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/SshAgentClient.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/SshAgentClient.java index 08483e4c20..cbcb4d240e 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/SshAgentClient.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/SshAgentClient.java @@ -11,10 +11,12 @@ package org.eclipse.jgit.internal.transport.sshd.agent; import java.io.IOException; import java.security.KeyPair; +import java.security.PrivateKey; import java.security.PublicKey; import java.text.MessageFormat; import java.util.AbstractMap; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; @@ -22,21 +24,27 @@ import java.util.concurrent.atomic.AtomicBoolean; import org.apache.sshd.agent.SshAgent; import org.apache.sshd.agent.SshAgentConstants; +import org.apache.sshd.agent.SshAgentKeyConstraint; import org.apache.sshd.common.SshException; import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.keyprovider.KeyPairProvider; import org.apache.sshd.common.session.SessionContext; import org.apache.sshd.common.util.buffer.Buffer; import org.apache.sshd.common.util.buffer.BufferException; import org.apache.sshd.common.util.buffer.BufferUtils; import org.apache.sshd.common.util.buffer.ByteArrayBuffer; +import org.apache.sshd.common.util.buffer.keys.BufferPublicKeyParser; +import org.apache.sshd.common.util.io.der.DERParser; import org.eclipse.jgit.internal.transport.sshd.SshdText; import org.eclipse.jgit.transport.sshd.agent.Connector; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * A client for an SSH2 agent. This client supports only querying identities and - * signature requests. + * A client for an SSH2 agent. This client supports querying identities, + * signature requests, and adding keys to an agent (with or without + * constraints). Removing keys is not supported, and the older SSH1 protocol is + * not supported. * * @see <a href="https://tools.ietf.org/html/draft-miller-ssh-agent-04">SSH * Agent Protocol, RFC draft</a> @@ -72,11 +80,18 @@ public class SshAgentClient implements SshAgent { } return false; } - boolean connected = connector != null && connector.connect(); - if (!connected) { + boolean connected; + try { + connected = connector != null && connector.connect(); + if (!connected && debugging) { + LOG.debug("No SSH agent"); //$NON-NLS-1$ + } + } catch (IOException e) { + // Agent not running? if (debugging) { - LOG.debug("No SSH agent (SSH_AUTH_SOCK not set)"); //$NON-NLS-1$ + LOG.debug("No SSH agent", e); //$NON-NLS-1$ } + throw e; } return connected; } @@ -127,14 +142,17 @@ public class SshAgentClient implements SshAgent { List<Map.Entry<PublicKey, String>> keys = new ArrayList<>( numberOfKeys); for (int i = 0; i < numberOfKeys; i++) { - PublicKey key = reply.getPublicKey(); + PublicKey key = readKey(reply); String comment = reply.getString(); - if (tracing) { - LOG.trace("Got SSH agent {} key: {} {}", //$NON-NLS-1$ - KeyUtils.getKeyType(key), - KeyUtils.getFingerPrint(key), comment); + if (key != null) { + if (tracing) { + LOG.trace("Got SSH agent {} key: {} {}", //$NON-NLS-1$ + KeyUtils.getKeyType(key), + KeyUtils.getFingerPrint(key), comment); + } + keys.add(new AbstractMap.SimpleImmutableEntry<>(key, + comment)); } - keys.add(new AbstractMap.SimpleImmutableEntry<>(key, comment)); } return keys; } catch (BufferException e) { @@ -216,6 +234,222 @@ public class SshAgentClient implements SshAgent { } } + @Override + public void addIdentity(KeyPair key, String comment, + SshAgentKeyConstraint... constraints) throws IOException { + boolean debugging = LOG.isDebugEnabled(); + if (!open(debugging)) { + return; + } + + // Neither Pageant 0.76 nor Win32-OpenSSH 8.6 support command + // SSH2_AGENTC_ADD_ID_CONSTRAINED. Adding a key with constraints will + // fail. The only work-around for users is not to use "confirm" or "time + // spec" with AddKeysToAgent, and not to use sk-* keys. + // + // With a true OpenSSH SSH agent, key constraints work. + byte cmd = (constraints != null && constraints.length > 0) + ? SshAgentConstants.SSH2_AGENTC_ADD_ID_CONSTRAINED + : SshAgentConstants.SSH2_AGENTC_ADD_IDENTITY; + byte[] message = null; + ByteArrayBuffer msg = new ByteArrayBuffer(); + try { + msg.putInt(0); + msg.putByte(cmd); + String keyType = KeyUtils.getKeyType(key); + if (KeyPairProvider.SSH_ED25519.equals(keyType)) { + // Apache MINA sshd 2.8.0 lacks support for writing ed25519 + // private keys to a buffer. + putEd25519Key(msg, key); + } else { + msg.putKeyPair(key); + } + msg.putString(comment == null ? "" : comment); //$NON-NLS-1$ + if (constraints != null) { + for (SshAgentKeyConstraint constraint : constraints) { + constraint.put(msg); + } + } + if (debugging) { + LOG.debug( + "addIdentity: adding {} key {} to SSH agent; comment {}", //$NON-NLS-1$ + keyType, KeyUtils.getFingerPrint(key.getPublic()), + comment); + } + message = msg.getCompactData(); + } finally { + // The message contains the private key data, so clear intermediary + // data ASAP. + msg.clear(); + } + Buffer reply; + try { + reply = rpc(cmd, message); + } finally { + Arrays.fill(message, (byte) 0); + } + int replyLength = reply.available(); + if (replyLength != 1) { + throw new SshException(MessageFormat.format( + SshdText.get().sshAgentReplyUnexpected, + MessageFormat.format( + SshdText.get().sshAgentPayloadLengthError, + Integer.valueOf(1), Integer.valueOf(replyLength)))); + + } + cmd = reply.getByte(); + if (cmd != SshAgentConstants.SSH_AGENT_SUCCESS) { + throw new SshException( + MessageFormat.format(SshdText.get().sshAgentReplyUnexpected, + SshAgentConstants.getCommandMessageName(cmd))); + } + } + + /** + * Writes an ed25519 {@link KeyPair} to a {@link Buffer}. OpenSSH specifies + * that it expects the 32 public key bytes, followed by 64 bytes formed by + * concatenating the 32 private key bytes with the 32 public key bytes. + * + * @param msg + * {@link Buffer} to write to + * @param key + * {@link KeyPair} to write + * @throws IOException + * if the private key cannot be written + */ + private static void putEd25519Key(Buffer msg, KeyPair key) + throws IOException { + Buffer tmp = new ByteArrayBuffer(36); + tmp.putRawPublicKeyBytes(key.getPublic()); + byte[] publicBytes = tmp.getBytes(); + msg.putString(KeyPairProvider.SSH_ED25519); + msg.putBytes(publicBytes); + // Next is the concatenation of the 32 byte private key value with the + // 32 bytes of the public key. + PrivateKey pk = key.getPrivate(); + String format = pk.getFormat(); + if (!"PKCS#8".equalsIgnoreCase(format)) { //$NON-NLS-1$ + throw new IOException(MessageFormat + .format(SshdText.get().sshAgentEdDSAFormatError, format)); + } + byte[] privateBytes = null; + byte[] encoded = pk.getEncoded(); + try { + privateBytes = asn1Parse(encoded, 32); + byte[] combined = Arrays.copyOf(privateBytes, 64); + Arrays.fill(privateBytes, (byte) 0); + privateBytes = combined; + System.arraycopy(publicBytes, 0, privateBytes, 32, 32); + msg.putBytes(privateBytes); + } finally { + if (privateBytes != null) { + Arrays.fill(privateBytes, (byte) 0); + } + Arrays.fill(encoded, (byte) 0); + } + } + + /** + * Extracts the private key bytes from an encoded ed25519 private key by + * parsing the bytes as ASN.1 according to RFC 5958 (PKCS #8 encoding): + * + * <pre> + * OneAsymmetricKey ::= SEQUENCE { + * version Version, + * privateKeyAlgorithm PrivateKeyAlgorithmIdentifier, + * privateKey PrivateKey, + * ... + * } + * + * Version ::= INTEGER + * PrivateKeyAlgorithmIdentifier ::= AlgorithmIdentifier + * PrivateKey ::= OCTET STRING + * + * AlgorithmIdentifier ::= SEQUENCE { + * algorithm OBJECT IDENTIFIER, + * parameters ANY DEFINED BY algorithm OPTIONAL + * } + * </pre> + * <p> + * and RFC 8410: "... when encoding a OneAsymmetricKey object, the private + * key is wrapped in a CurvePrivateKey object and wrapped by the OCTET + * STRING of the 'privateKey' field." + * </p> + * + * <pre> + * CurvePrivateKey ::= OCTET STRING + * </pre> + * + * @param encoded + * encoded private key to extract the private key bytes from + * @param n + * number of bytes expected + * @return the extracted private key bytes; of length {@code n} + * @throws IOException + * if the private key cannot be extracted + * @see <a href="https://tools.ietf.org/html/rfc5958">RFC 5958</a> + * @see <a href="https://tools.ietf.org/html/rfc8410">RFC 8410</a> + */ + private static byte[] asn1Parse(byte[] encoded, int n) throws IOException { + byte[] privateKey = null; + try (DERParser byteParser = new DERParser(encoded); + DERParser oneAsymmetricKey = byteParser.readObject() + .createParser()) { + oneAsymmetricKey.readObject(); // skip version + oneAsymmetricKey.readObject(); // skip algorithm identifier + privateKey = oneAsymmetricKey.readObject().getValue(); + // The last n bytes of this must be the private key bytes + return Arrays.copyOfRange(privateKey, + privateKey.length - n, privateKey.length); + } finally { + if (privateKey != null) { + Arrays.fill(privateKey, (byte) 0); + } + } + } + + /** + * A safe version of {@link Buffer#getPublicKey()}. Upon return the + * buffers's read position is always after the key blob; any exceptions + * thrown by trying to read the key are logged and <em>not</em> propagated. + * <p> + * This is needed because an SSH agent might contain and deliver keys that + * we cannot handle (for instance ed448 keys). + * </p> + * + * @param buffer + * to read the key from + * @return the {@link PublicKey}, or {@code null} if the key could not be + * read + * @throws BufferException + * if the length of the key blob cannot be read or is corrupted + */ + private static PublicKey readKey(Buffer buffer) throws BufferException { + int endOfBuffer = buffer.wpos(); + int keyLength = buffer.getInt(); + int afterKey = buffer.rpos() + keyLength; + if (keyLength <= 0 || afterKey > endOfBuffer) { + throw new BufferException( + MessageFormat.format(SshdText.get().sshAgentWrongKeyLength, + Integer.toString(keyLength), + Integer.toString(buffer.rpos()), + Integer.toString(endOfBuffer))); + } + // Limit subsequent reads to the public key blob + buffer.wpos(afterKey); + try { + return buffer.getRawPublicKey(BufferPublicKeyParser.DEFAULT); + } catch (Exception e) { + LOG.warn(SshdText.get().sshAgentUnknownKey, e); + return null; + } finally { + // Restore real buffer end + buffer.wpos(endOfBuffer); + // Set the read position to after this key, even if failed + buffer.rpos(afterKey); + } + } + private Buffer rpc(byte command, byte[] message) throws IOException { return new ByteArrayBuffer(connector.rpc(command, message)); } @@ -230,11 +464,6 @@ public class SshAgentClient implements SshAgent { } @Override - public void addIdentity(KeyPair key, String comment) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override public void removeIdentity(PublicKey key) throws IOException { throw new UnsupportedOperationException(); } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java index c270b44956..b94ccc6d4f 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2022 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 @@ -51,6 +51,9 @@ import org.apache.sshd.sftp.client.SftpClientFactory; import org.apache.sshd.sftp.common.SftpException; 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.AuthenticationLogger; import org.eclipse.jgit.internal.transport.sshd.JGitSshClient; import org.eclipse.jgit.internal.transport.sshd.SshdText; import org.eclipse.jgit.transport.FtpChannel; @@ -118,6 +121,7 @@ public class SshdSession implements RemoteSession2 { ClientSession resultSession = null; ClientSession proxySession = null; PortForwardingTracker portForward = null; + AuthenticationLogger authLog = null; try { if (!hops.isEmpty()) { URIish hop = hops.remove(0); @@ -138,7 +142,11 @@ public class SshdSession implements RemoteSession2 { JGitSshClient.LOCAL_FORWARD_ADDRESS, portForward.getBoundAddress()); } - resultSession = connect(hostConfig, context, timeout); + int timeoutInSec = OpenSshConfigFile.timeSpec( + hostConfig.getProperty(SshConstants.CONNECT_TIMEOUT)); + resultSession = connect(hostConfig, context, + timeoutInSec > 0 ? Duration.ofSeconds(timeoutInSec) + : timeout); if (proxySession != null) { final PortForwardingTracker tracker = portForward; final ClientSession pSession = proxySession; @@ -160,6 +168,7 @@ public class SshdSession implements RemoteSession2 { resultSession.addCloseFutureListener(listener); } // Authentication timeout is by default 2 minutes. + authLog = new AuthenticationLogger(resultSession); resultSession.auth().verify(resultSession.getAuthTimeout()); return resultSession; } catch (IOException e) { @@ -168,15 +177,32 @@ public class SshdSession implements RemoteSession2 { close(resultSession, e); if (e instanceof SshException && ((SshException) e) .getDisconnectCode() == SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) { - // Ensure the user gets to know on which URI the authentication - // was denied. + String message = format(SshdText.get().loginDenied, host, + Integer.toString(port)); throw new TransportException(target, - format(SshdText.get().loginDenied, host, - Integer.toString(port)), - e); + withAuthLog(message, authLog), e); + } else if (e instanceof SshException && e + .getCause() instanceof AuthenticationCanceledException) { + String message = e.getCause().getMessage(); + throw new TransportException(target, + withAuthLog(message, authLog), e.getCause()); } throw e; + } finally { + if (authLog != null) { + authLog.clear(); + } + } + } + + private String withAuthLog(String message, AuthenticationLogger authLog) { + if (authLog != null) { + String log = String.join(System.lineSeparator(), authLog.getLog()); + if (!log.isEmpty()) { + return message + System.lineSeparator() + log; + } } + return message; } private ClientSession connect(HostConfigEntry config, diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java index 58cf8e1ddd..c792c1889c 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2022 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 @@ -13,6 +13,7 @@ import java.io.Closeable; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.security.KeyPair; import java.time.Duration; @@ -34,7 +35,6 @@ import org.apache.sshd.client.auth.UserAuthFactory; import org.apache.sshd.client.auth.keyboard.UserAuthKeyboardInteractiveFactory; import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; import org.apache.sshd.common.NamedFactory; -import org.apache.sshd.common.SshException; 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; @@ -44,7 +44,6 @@ 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; @@ -243,6 +242,12 @@ public class SshdSessionFactory extends SshSessionFactory implements Closeable { JGitSshClient.PREFERRED_AUTHENTICATIONS, defaultAuths); } + try { + jgitClient.setAttribute(JGitSshClient.HOME_DIRECTORY, + home.getAbsoluteFile().toPath()); + } catch (SecurityException | InvalidPathException e) { + // Ignore + } // Other things? return client; }); @@ -255,13 +260,7 @@ public class SshdSessionFactory extends SshSessionFactory implements Closeable { 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); + throw new TransportException(uri, e.getMessage(), e); } } |