From c56fa51709278f2be4e155ae5fbad270188cbe64 Mon Sep 17 00:00:00 2001 From: Thomas Wolf Date: Wed, 17 Oct 2018 20:22:26 +0200 Subject: [PATCH] Apache MINA sshd: use NumberOfPasswordPrompts for encrypted keys sshd only asks exactly once for the password. C.f. upstream issue SSHD-850.[1] So we have to work around this limitation for now. Once we move to sshd > 2.1.0, this can be simplified somewhat. [1] https://issues.apache.org/jira/browse/SSHD-850 Bug: 520927 Change-Id: Id65650228486c5ed30affa9c62eac982e01ae207 Signed-off-by: Thomas Wolf --- .../META-INF/MANIFEST.MF | 2 + .../transport/sshd/SshdText.properties | 5 + .../sshd/CachingKeyPairProvider.java | 8 +- .../sshd/EncryptedFileKeyPairProvider.java | 159 ++++++++++++++ .../transport/sshd/JGitSshClient.java | 7 + .../internal/transport/sshd/SshdText.java | 5 + .../sshd/IdentityPasswordProvider.java | 195 +++++++++++++++++- .../sshd/RepeatingFilePasswordProvider.java | 120 +++++++++++ .../jgit/transport/ssh/SshTestBase.java | 47 ++++- .../jgit/transport/ssh/SshTestHarness.java | 28 ++- 10 files changed, 555 insertions(+), 21 deletions(-) create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/EncryptedFileKeyPairProvider.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/RepeatingFilePasswordProvider.java diff --git a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF index 987f8dcdc..caeff5363 100644 --- a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF +++ b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF @@ -48,6 +48,7 @@ Import-Package: org.apache.sshd.agent;version="[2.0.0,2.1.0)", org.apache.sshd.common.channel;version="[2.0.0,2.1.0)", org.apache.sshd.common.compression;version="[2.0.0,2.1.0)", org.apache.sshd.common.config.keys;version="[2.0.0,2.1.0)", + org.apache.sshd.common.config.keys.loader;version="[2.0.0,2.1.0)", org.apache.sshd.common.digest;version="[2.0.0,2.1.0)", org.apache.sshd.common.forward;version="[2.0.0,2.1.0)", org.apache.sshd.common.future;version="[2.0.0,2.1.0)", @@ -66,6 +67,7 @@ Import-Package: org.apache.sshd.agent;version="[2.0.0,2.1.0)", org.apache.sshd.common.util.io;version="[2.0.0,2.1.0)", org.apache.sshd.common.util.logging;version="[2.0.0,2.1.0)", org.apache.sshd.common.util.net;version="[2.0.0,2.1.0)", + org.apache.sshd.common.util.security;version="[2.0.0,2.1.0)", org.apache.sshd.server.auth;version="[2.0.0,2.1.0)", org.eclipse.jgit.annotations;version="[5.2.0,5.3.0)", org.eclipse.jgit.errors;version="[5.2.0,5.3.0)", diff --git a/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties index 369c9784d..12afedd8b 100644 --- a/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties +++ b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties @@ -10,8 +10,13 @@ gssapiFailure=GSS-API error for mechanism OID {0} gssapiInitFailure=GSS-API initialization failure for mechanism {0} gssapiUnexpectedMechanism=Server {0} replied with unknown mechanism name ''{1}'' in {2} authentication gssapiUnexpectedMessage=Received unexpected ssh message {1} in {0} authentication +identityFileCannotDecrypt=Given passphrase cannot decrypt identity {0} +identityFileNoKey=No keys found in identity {0} +identityFileMultipleKeys=Multiple key pairs found in identity {0} +identityFileUnsupportedFormat=Unsupported format in identity {0} keyEncryptedMsg=Key ''{0}'' is encrypted. Enter the passphrase to decrypt it. keyEncryptedPrompt=Passphrase +keyEncryptedRetry=Encrypted key ''{0}'' could not be decrypted. Enter the passphrase again. keyLoadFailed=Could not load key ''{0}'' knownHostsCouldNotUpdate=Could not update known hosts file {0} knownHostsFileLockedRead=Could not read known hosts file (locked) {0} 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 dcd17af2f..ad2ff5256 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 @@ -56,20 +56,20 @@ import java.util.List; import java.util.NoSuchElementException; import java.util.concurrent.CancellationException; -import org.apache.sshd.common.keyprovider.FileKeyPairProvider; import org.eclipse.jgit.transport.sshd.KeyCache; /** - * A {@link FileKeyPairProvider} that uses an external {@link KeyCache}. + * A {@link EncryptedFileKeyPairProvider} that uses an external + * {@link KeyCache}. */ -public class CachingKeyPairProvider extends FileKeyPairProvider { +public class CachingKeyPairProvider extends EncryptedFileKeyPairProvider { private final KeyCache cache; /** * Creates a new {@link CachingKeyPairProvider} using the given * {@link KeyCache}. If the cache is {@code null}, this is a simple - * {@link FileKeyPairProvider}. + * {@link EncryptedFileKeyPairProvider}. * * @param paths * to load keys from diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/EncryptedFileKeyPairProvider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/EncryptedFileKeyPairProvider.java new file mode 100644 index 000000000..2e201d882 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/EncryptedFileKeyPairProvider.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * and other copyright owners as documented in the project's IP log. + * + * This program and the accompanying materials are made available + * under the terms of the Eclipse Distribution License v1.0 which + * accompanies this distribution, is reproduced below, and is + * available at http://www.eclipse.org/org/documents/edl-v10.php + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or + * without modification, are permitted provided that the following + * conditions are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * + * - Neither the name of the Eclipse Foundation, Inc. nor the + * names of its contributors may be used to endorse or promote + * products derived from this software without specific prior + * written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.eclipse.jgit.internal.transport.sshd; + +import static java.text.MessageFormat.format; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; +import java.security.KeyPair; +import java.security.NoSuchProviderException; +import java.security.PrivateKey; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; + +import javax.security.auth.DestroyFailedException; + +import org.apache.sshd.common.config.keys.FilePasswordProvider; +import org.apache.sshd.common.config.keys.loader.KeyPairResourceParser; +import org.apache.sshd.common.keyprovider.FileKeyPairProvider; +import org.apache.sshd.common.util.io.IoUtils; +import org.apache.sshd.common.util.security.SecurityUtils; +import org.eclipse.jgit.transport.sshd.RepeatingFilePasswordProvider; +import org.eclipse.jgit.transport.sshd.RepeatingFilePasswordProvider.ResourceDecodeResult; + +/** + * A {@link FileKeyPairProvider} that asks repeatedly for a passphrase for an + * encrypted private key if the {@link FilePasswordProvider} is a + * {@link RepeatingFilePasswordProvider}. + */ +public class EncryptedFileKeyPairProvider extends FileKeyPairProvider { + + // TODO: remove this class once we're based on sshd > 2.1.0. See upstream + // issue SSHD-850 https://issues.apache.org/jira/browse/SSHD-850 and commit + // https://github.com/apache/mina-sshd/commit/f19bd2e34 + + /** + * Creates a new {@link EncryptedFileKeyPairProvider} for the given + * {@link Path}s. + * + * @param paths + * to read keys from + */ + public EncryptedFileKeyPairProvider(List paths) { + super(paths); + } + + @Override + protected KeyPair doLoadKey(String resourceKey, InputStream inputStream, + FilePasswordProvider provider) + throws IOException, GeneralSecurityException { + if (!(provider instanceof RepeatingFilePasswordProvider)) { + return super.doLoadKey(resourceKey, inputStream, provider); + } + KeyPairResourceParser parser = SecurityUtils.getKeyPairResourceParser(); + if (parser == null) { + // This is an internal configuration error, thus no translation. + throw new NoSuchProviderException( + "No registered key-pair resource parser"); //$NON-NLS-1$ + } + RepeatingFilePasswordProvider realProvider = (RepeatingFilePasswordProvider) provider; + // Read the stream now so that we can process the content several + // times. + List lines = IoUtils.readAllLines(inputStream); + Collection ids = null; + while (ids == null) { + try { + ids = parser.loadKeyPairs(resourceKey, realProvider, lines); + realProvider.handleDecodeAttemptResult(resourceKey, "", null); //$NON-NLS-1$ + // No exception; success. Exit the loop even if ids is still + // null! + break; + } catch (IOException | GeneralSecurityException + | RuntimeException e) { + ResourceDecodeResult loadResult = realProvider + .handleDecodeAttemptResult(resourceKey, "", e); //$NON-NLS-1$ + if (loadResult == null + || loadResult == ResourceDecodeResult.TERMINATE) { + throw e; + } else if (loadResult == ResourceDecodeResult.RETRY) { + continue; + } + // IGNORE doesn't make any sense here, but OK, let's ignore it. + // ids == null, so we'll throw an exception below. + break; + } + } + if (ids == null) { + // The javadoc on loadKeyPairs says it might return null if no + // key pair found. Bad API. + throw new InvalidKeyException( + format(SshdText.get().identityFileNoKey, resourceKey)); + } + Iterator keys = ids.iterator(); + if (!keys.hasNext()) { + throw new InvalidKeyException(format( + SshdText.get().identityFileUnsupportedFormat, resourceKey)); + } + KeyPair result = keys.next(); + if (keys.hasNext()) { + log.warn(format(SshdText.get().identityFileMultipleKeys, + resourceKey)); + keys.forEachRemaining(k -> { + PrivateKey pk = k.getPrivate(); + if (pk != null) { + try { + pk.destroy(); + } catch (DestroyFailedException e) { + // Ignore + } + } + }); + } + 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 27cf05077..d3289259e 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 @@ -65,6 +65,7 @@ import org.apache.sshd.client.future.ConnectFuture; import org.apache.sshd.client.future.DefaultConnectFuture; import org.apache.sshd.client.session.ClientSessionImpl; import org.apache.sshd.client.session.SessionFactory; +import org.apache.sshd.common.config.keys.FilePasswordProvider; import org.apache.sshd.common.future.SshFutureListener; import org.apache.sshd.common.io.IoConnectFuture; import org.apache.sshd.common.io.IoSession; @@ -75,6 +76,7 @@ import org.apache.sshd.common.util.ValidateUtils; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.SshConstants; import org.eclipse.jgit.transport.sshd.KeyCache; +import org.eclipse.jgit.transport.sshd.RepeatingFilePasswordProvider; /** * Customized {@link SshClient} for JGit. It creates specialized @@ -195,6 +197,11 @@ public class JGitSshClient extends SshClient { int numberOfPasswordPrompts = getNumberOfPasswordPrompts(hostConfig); session.getProperties().put(PASSWORD_PROMPTS, Integer.valueOf(numberOfPasswordPrompts)); + FilePasswordProvider provider = getFilePasswordProvider(); + if (provider instanceof RepeatingFilePasswordProvider) { + ((RepeatingFilePasswordProvider) provider) + .setAttempts(numberOfPasswordPrompts); + } FileKeyPairProvider ourConfiguredKeysProvider = null; List identities = hostConfig.getIdentities().stream() .map(s -> { 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 e7e5d8fcc..bd9b2a254 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 @@ -30,8 +30,13 @@ public final class SshdText extends TranslationBundle { /***/ public String gssapiInitFailure; /***/ public String gssapiUnexpectedMechanism; /***/ public String gssapiUnexpectedMessage; + /***/ public String identityFileCannotDecrypt; + /***/ public String identityFileNoKey; + /***/ public String identityFileMultipleKeys; + /***/ public String identityFileUnsupportedFormat; /***/ public String keyEncryptedMsg; /***/ public String keyEncryptedPrompt; + /***/ public String keyEncryptedRetry; /***/ public String keyLoadFailed; /***/ public String knownHostsCouldNotUpdate; /***/ public String knownHostsFileLockedRead; diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/IdentityPasswordProvider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/IdentityPasswordProvider.java index 5a0bd7fdc..231d3f4eb 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/IdentityPasswordProvider.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/IdentityPasswordProvider.java @@ -46,25 +46,98 @@ import static java.text.MessageFormat.format; import java.io.IOException; import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CancellationException; -import org.apache.sshd.common.config.keys.FilePasswordProvider; +import org.eclipse.jgit.annotations.NonNull; import org.eclipse.jgit.internal.transport.sshd.SshdText; import org.eclipse.jgit.transport.CredentialItem; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.URIish; /** - * A {@link FilePasswordProvider} based on a {@link CredentialsProvider}. + * A {@link RepeatingFilePasswordProvider} based on a + * {@link CredentialsProvider}. * * @since 5.2 */ -public class IdentityPasswordProvider implements FilePasswordProvider { +public class IdentityPasswordProvider implements RepeatingFilePasswordProvider { private CredentialsProvider provider; + /** + * The number of times to ask successively for a password for a given + * identity resource. + */ + private int attempts = 1; + + /** + * A simple state object for repeated attempts to get a password for a + * resource. + */ + protected static class State { + + private int count = 0; + + private char[] password; + + /** + * Obtains the current count. The initial count is zero. + * + * @return the count + */ + public int getCount() { + return count; + } + + /** + * Increments the current count. Should be called for each new attempt + * to get a password. + * + * @return the incremented count. + */ + public int incCount() { + return ++count; + } + + /** + * Remembers the password. + * + * @param password + * the password + */ + public void setPassword(char[] password) { + if (this.password != null) { + Arrays.fill(this.password, '\000'); + } + if (password != null) { + this.password = password.clone(); + } else { + this.password = null; + } + } + + /** + * Retrieves the password from the current attempt. + * + * @return the password, or {@code null} if none was obtained + */ + public char[] getPassword() { + return password; + } + } + + /** + * Counts per resource key. + */ + private final Map current = new HashMap<>(); + /** * Creates a new {@link IdentityPasswordProvider} to get the passphrase for * an encrypted identity. @@ -76,6 +149,56 @@ public class IdentityPasswordProvider implements FilePasswordProvider { this.provider = provider; } + @Override + public void setAttempts(int numberOfPasswordPrompts) { + RepeatingFilePasswordProvider.super.setAttempts( + numberOfPasswordPrompts); + attempts = numberOfPasswordPrompts; + } + + @Override + public int getAttempts() { + return Math.max(1, attempts); + } + + @Override + public String getPassword(String resourceKey) throws IOException { + char[] pass = getPassword(resourceKey, + current.computeIfAbsent(resourceKey, r -> new State())); + if (pass == null) { + return null; + } + try { + return new String(pass); + } finally { + Arrays.fill(pass, '\000'); + } + } + + /** + * Retrieves a password to decrypt a private key. + * + * @param resourceKey + * identifying the resource to obtain a password for + * @param state + * encapsulating state information about attempts to get the + * password + * @return the password, or {@code null} or the empty string if none + * available. + * @throws IOException + * if an error occurs + */ + protected char[] getPassword(String resourceKey, @NonNull State state) + throws IOException { + state.setPassword(null); + state.incCount(); + String message = state.count == 1 ? SshdText.get().keyEncryptedMsg + : SshdText.get().keyEncryptedRetry; + char[] pass = getPassword(resourceKey, message); + state.setPassword(pass); + return pass; + } + /** * Creates a {@link URIish} from a given string. The * {@link CredentialsProvider} uses uris as resource identifications. @@ -92,15 +215,14 @@ public class IdentityPasswordProvider implements FilePasswordProvider { } } - @Override - public String getPassword(String resourceKey) throws IOException { + private char[] getPassword(String resourceKey, String message) { if (provider == null) { return null; } URIish file = toUri(resourceKey); List items = new ArrayList<>(2); items.add(new CredentialItem.InformationalMessage( - format(SshdText.get().keyEncryptedMsg, resourceKey))); + format(message, resourceKey))); CredentialItem.Password password = new CredentialItem.Password( SshdText.get().keyEncryptedPrompt); items.add(password); @@ -111,10 +233,69 @@ public class IdentityPasswordProvider implements FilePasswordProvider { throw new CancellationException( SshdText.get().authenticationCanceled); } - return new String(pass); + return pass.clone(); } finally { password.clear(); } } + /** + * Invoked to inform the password provider about the decoding result. + * + * @param resourceKey + * the resource key + * @param state + * associated with this key + * @param password + * the password that was attempted + * @param err + * the attempt result - {@code null} for success + * @return how to proceed in case of error + * @throws IOException + * @throws GeneralSecurityException + * @see #handleDecodeAttemptResult(String, String, Exception) + */ + protected ResourceDecodeResult handleDecodeAttemptResult(String resourceKey, + State state, char[] password, Exception err) + throws IOException, GeneralSecurityException { + if (err == null) { + return null; + } else if (err instanceof GeneralSecurityException) { + throw new InvalidKeyException( + format(SshdText.get().identityFileCannotDecrypt, + resourceKey), + err); + } else { + // Unencrypted key (state == null && password == null), or exception + // before having asked for the password (state != null && password + // == null; might also be a user cancellation), or number of + // attempts exhausted. + if (state == null || password == null + || state.getCount() >= attempts) { + return ResourceDecodeResult.TERMINATE; + } + return ResourceDecodeResult.RETRY; + } + } + + @Override + public ResourceDecodeResult handleDecodeAttemptResult(String resourceKey, + String password, Exception err) + throws IOException, GeneralSecurityException { + ResourceDecodeResult result = null; + State state = null; + try { + state = current.get(resourceKey); + result = handleDecodeAttemptResult(resourceKey, state, + state == null ? null : state.getPassword(), err); + } finally { + if (state != null) { + state.setPassword(null); + } + if (result != ResourceDecodeResult.RETRY) { + current.remove(resourceKey); + } + } + return result; + } } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/RepeatingFilePasswordProvider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/RepeatingFilePasswordProvider.java new file mode 100644 index 000000000..da8b76844 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/RepeatingFilePasswordProvider.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * and other copyright owners as documented in the project's IP log. + * + * This program and the accompanying materials are made available + * under the terms of the Eclipse Distribution License v1.0 which + * accompanies this distribution, is reproduced below, and is + * available at http://www.eclipse.org/org/documents/edl-v10.php + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or + * without modification, are permitted provided that the following + * conditions are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * + * - Neither the name of the Eclipse Foundation, Inc. nor the + * names of its contributors may be used to endorse or promote + * products derived from this software without specific prior + * written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.eclipse.jgit.transport.sshd; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +import org.apache.sshd.common.config.keys.FilePasswordProvider; + +/** + * A {@link FilePasswordProvider} augmented to support repeatedly asking for + * passwords. + * + * @since 5.2 + */ +public interface RepeatingFilePasswordProvider extends FilePasswordProvider { + + /** + * Define the maximum number of attempts to get a password that should be + * attempted for one identity resource through this provider. + * + * @param numberOfPasswordPrompts + * number of times to ask for a password, >= 1. + */ + default void setAttempts(int numberOfPasswordPrompts) { + if (numberOfPasswordPrompts <= 0) { + throw new IllegalArgumentException( + "Number of password prompts must be >= 1"); //$NON-NLS-1$ + } + } + + /** + * Gets the maximum number of attempts to get a password that should be + * attempted for one identity resource through this provider. + * + * @return the maximum number of attempts to try, always >= 1. + */ + default int getAttempts() { + return 1; + } + + // The following part of this interface is from the upstream resolution of + // SSHD-850. See https://github.com/apache/mina-sshd/commit/f19bd2e34 . + // TODO: remove this once we move to sshd > 2.1.0 + + /** + * Result value of + * {@link RepeatingFilePasswordProvider#handleDecodeAttemptResult(String, String, Exception)}. + */ + public enum ResourceDecodeResult { + /** Re-throw the decoding exception. */ + TERMINATE, + /** Retry the decoding process - including password prompt. */ + RETRY, + /** Skip attempt and see if we can proceed without the key. */ + IGNORE; + } + + /** + * Invoked to inform the password provider about the decoding result. + * Note: any exception thrown from this method (including if called + * to inform about success) will be propagated instead of the original (if + * any was reported) + * + * @param resourceKey + * The resource key representing the private file + * @param password + * The password that was attempted + * @param err + * The attempt result - {@code null} for success + * @return How to proceed in case of error - ignored if invoked in + * order to report success. Note: {@code null} is same as + * {@link ResourceDecodeResult#TERMINATE}. + * @throws IOException + * @throws GeneralSecurityException + */ + ResourceDecodeResult handleDecodeAttemptResult(String resourceKey, + String password, Exception err) + throws IOException, GeneralSecurityException; +} diff --git a/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestBase.java b/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestBase.java index 3e4493119..86dbc4edc 100644 --- a/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestBase.java +++ b/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestBase.java @@ -54,12 +54,10 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.List; -import java.util.Map; import org.eclipse.jgit.api.errors.TransportException; import org.eclipse.jgit.transport.CredentialItem; import org.eclipse.jgit.transport.JschConfigSessionFactory; -import org.eclipse.jgit.transport.URIish; import org.junit.Test; import org.junit.experimental.theories.DataPoints; import org.junit.experimental.theories.Theory; @@ -221,6 +219,45 @@ public abstract class SshTestBase extends SshTestHarness { provider.getLog().size()); } + @Test(expected = TransportException.class) + public void testSshEncryptedUsedKeyWrongPassword() throws Exception { + File encryptedKey = new File(sshDir, "id_dsa_test_key"); + copyTestResource("id_dsa_testpass", encryptedKey); + File encryptedPublicKey = new File(sshDir, "id_dsa_test_key.pub"); + copyTestResource("id_dsa_testpass.pub", encryptedPublicKey); + server.setTestUserPublicKey(encryptedPublicKey.toPath()); + TestCredentialsProvider provider = new TestCredentialsProvider( + "wrongpass"); + cloneWith("ssh://localhost/doesntmatter", // + defaultCloneDir, provider, // + "Host localhost", // + "HostName localhost", // + "Port " + testPort, // + "User " + TEST_USER, // + "NumberOfPasswordPrompts 1", // + "IdentityFile " + encryptedKey.getAbsolutePath()); + } + + @Test + public void testSshEncryptedUsedKeySeveralPassword() throws Exception { + File encryptedKey = new File(sshDir, "id_dsa_test_key"); + copyTestResource("id_dsa_testpass", encryptedKey); + File encryptedPublicKey = new File(sshDir, "id_dsa_test_key.pub"); + copyTestResource("id_dsa_testpass.pub", encryptedPublicKey); + server.setTestUserPublicKey(encryptedPublicKey.toPath()); + TestCredentialsProvider provider = new TestCredentialsProvider( + "wrongpass", "wrongpass2", "testpass"); + cloneWith("ssh://localhost/doesntmatter", // + defaultCloneDir, provider, // + "Host localhost", // + "HostName localhost", // + "Port " + testPort, // + "User " + TEST_USER, // + "IdentityFile " + encryptedKey.getAbsolutePath()); + assertEquals("CredentialsProvider should have been called 3 times", 3, + provider.getLog().size()); + } + @Test(expected = TransportException.class) public void testSshWithoutKnownHosts() throws Exception { assertTrue("Could not delete known_hosts", knownHosts.delete()); @@ -248,7 +285,7 @@ public abstract class SshTestBase extends SshTestHarness { "Port " + testPort, // "User " + TEST_USER, // "IdentityFile " + privateKey1.getAbsolutePath()); - Map> messages = provider.getLog(); + List messages = provider.getLog(); assertFalse("Expected user interaction", messages.isEmpty()); if (getSessionFactory() instanceof JschConfigSessionFactory) { // JSch doesn't create a non-existing file. @@ -361,8 +398,8 @@ public abstract class SshTestBase extends SshTestHarness { } catch (Exception e) { assertEquals("Expected to be told about the modified key", 1, provider.getLog().size()); - assertTrue("Only messages expected", provider.getLog().values() - .stream().flatMap(List::stream).allMatch( + assertTrue("Only messages expected", provider.getLog().stream() + .flatMap(l -> l.getItems().stream()).allMatch( c -> c instanceof CredentialItem.InformationalMessage)); throw e; } diff --git a/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestHarness.java b/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestHarness.java index 347c72b3d..59925a5a1 100644 --- a/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestHarness.java +++ b/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestHarness.java @@ -56,12 +56,11 @@ import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Iterator; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import org.eclipse.jgit.api.CloneCommand; import org.eclipse.jgit.api.Git; @@ -420,15 +419,34 @@ public abstract class SshTestHarness extends RepositoryTestCase { return true; } - private Map> log = new LinkedHashMap<>(); + private List log = new ArrayList<>(); private void logItems(URIish uri, CredentialItem... items) { - log.put(uri, Arrays.asList(items)); + log.add(new LogEntry(uri, Arrays.asList(items))); } - public Map> getLog() { + public List getLog() { return log; } } + protected static class LogEntry { + + private URIish uri; + + private List items; + + public LogEntry(URIish uri, List items) { + this.uri = uri; + this.items = items; + } + + public URIish getURIish() { + return uri; + } + + public List getItems() { + return items; + } + } }