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 <thomas.wolf@paranor.ch>
This commit is contained in:
Thomas Wolf 2018-10-17 20:22:26 +02:00 committed by Matthias Sohn
parent c949da0d5f
commit c56fa51709
10 changed files with 555 additions and 21 deletions

View File

@ -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)",

View File

@ -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}

View File

@ -56,20 +56,20 @@
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

View File

@ -0,0 +1,159 @@
/*
* Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch>
* 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<Path> 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<String> lines = IoUtils.readAllLines(inputStream);
Collection<KeyPair> 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<KeyPair> 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;
}
}

View File

@ -65,6 +65,7 @@
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.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 @@ private JGitClientSession createSession(IoSession ioSession,
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<Path> identities = hostConfig.getIdentities().stream()
.map(s -> {

View File

@ -30,8 +30,13 @@ public static SshdText get() {
/***/ 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;

View File

@ -46,25 +46,98 @@
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<String, State> current = new HashMap<>();
/**
* Creates a new {@link IdentityPasswordProvider} to get the passphrase for
* an encrypted identity.
@ -76,6 +149,56 @@ public IdentityPasswordProvider(CredentialsProvider provider) {
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 @@ protected URIish toUri(String resourceKey) {
}
}
@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<CredentialItem> 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 String getPassword(String resourceKey) throws IOException {
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;
}
}

View File

@ -0,0 +1,120 @@
/*
* Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch>
* 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.
* <b>Note:</b> 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 <U>private</U> 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 - <u>ignored</u> if invoked in
* order to report success. <b>Note:</b> {@code null} is same as
* {@link ResourceDecodeResult#TERMINATE}.
* @throws IOException
* @throws GeneralSecurityException
*/
ResourceDecodeResult handleDecodeAttemptResult(String resourceKey,
String password, Exception err)
throws IOException, GeneralSecurityException;
}

View File

@ -54,12 +54,10 @@
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 void testSshEncryptedUsedKeyCached() throws Exception {
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 void testSshWithoutKnownHostsWithProviderAsk()
"Port " + testPort, //
"User " + TEST_USER, //
"IdentityFile " + privateKey1.getAbsolutePath());
Map<URIish, List<CredentialItem>> messages = provider.getLog();
List<LogEntry> 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 void testSshModifiedHostKeyWithProviderDeny() throws Exception {
} 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;
}

View File

@ -56,12 +56,11 @@
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 boolean get(URIish uri, CredentialItem... items)
return true;
}
private Map<URIish, List<CredentialItem>> log = new LinkedHashMap<>();
private List<LogEntry> 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<URIish, List<CredentialItem>> getLog() {
public List<LogEntry> getLog() {
return log;
}
}
protected static class LogEntry {
private URIish uri;
private List<CredentialItem> items;
public LogEntry(URIish uri, List<CredentialItem> items) {
this.uri = uri;
this.items = items;
}
public URIish getURIish() {
return uri;
}
public List<CredentialItem> getItems() {
return items;
}
}
}