ssh-keygen produces, most recent first. id_dsa is not among
+ * them: ssh-dss has been disabled by default in OpenSSH for years, so a DSA key is the one least likely
+ * to be accepted by the server we are about to reach.
+ */
+ private static final String[] PRIVATE_KEY_NAMES = {"id_ed25519", "id_ecdsa", "id_rsa"};
+
+ /**
+ * The same names, with Ed25519 demoted to last. Used when this runtime cannot do Ed25519, so that a key
+ * it can actually use wins -- while still falling back to id_ed25519 when that is the only
+ * key present, which is the right answer for wagon-ssh-external, where the host's own scp
+ * does the cryptography and the JVM's capabilities do not apply.
+ */
+ private static final String[] PRIVATE_KEY_NAMES_WITHOUT_ED25519 = {"id_ecdsa", "id_rsa", "id_ed25519"};
+
+ /**
+ * The names to look for, in order. Package-private so both orderings can be tested on any runtime.
+ */
+ static String[] preferredPrivateKeyNames(boolean ed25519Available) {
+ return ed25519Available ? PRIVATE_KEY_NAMES : PRIVATE_KEY_NAMES_WITHOUT_ED25519;
+ }
+
+ /**
+ * Whether this runtime can use an Ed25519 key. The JDK grew EdDSA in Java 15; before that a provider
+ * such as Bouncy Castle has to supply it, which JSch will use when it is on the class path.
+ */
+ private static boolean isEd25519Available() {
+ try {
+ KeyFactory.getInstance("Ed25519");
+ return true;
+ } catch (NoSuchAlgorithmException e) {
+ // not in this JDK; a provider may still supply it
+ }
+
+ try {
+ Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider");
+ return true;
+ } catch (ClassNotFoundException e) {
+ return false;
+ }
+ }
+
private static File findPrivateKey() {
String privateKeyDirectory = System.getProperty("wagon.privateKeyDirectory");
@@ -109,16 +153,14 @@ private static File findPrivateKey() {
privateKeyDirectory = System.getProperty("user.home");
}
- File privateKey = new File(privateKeyDirectory, ".ssh/id_dsa");
-
- if (!privateKey.exists()) {
- privateKey = new File(privateKeyDirectory, ".ssh/id_rsa");
- if (!privateKey.exists()) {
- privateKey = null;
+ for (String name : preferredPrivateKeyNames(isEd25519Available())) {
+ File privateKey = new File(privateKeyDirectory, ".ssh/" + name);
+ if (privateKey.exists()) {
+ return privateKey;
}
}
- return privateKey;
+ return null;
}
public static void createZip(List~/.ssh.
+ */
+public class ScpHelperTest {
+
+ @Test
+ public void ed25519IsPreferredWhenTheRuntimeCanUseIt() {
+ assertArrayEquals(new String[] {"id_ed25519", "id_ecdsa", "id_rsa"}, ScpHelper.preferredPrivateKeyNames(true));
+ }
+
+ /**
+ * The JDK grew EdDSA in Java 15. On anything older, and without a provider supplying it, an Ed25519 key
+ * cannot be used at all -- so preferring it would fail the connection outright even though a usable
+ * id_rsa is sitting next to it.
+ */
+ @Test
+ public void ed25519IsDemotedWhenTheRuntimeCannotUseIt() {
+ assertArrayEquals(new String[] {"id_ecdsa", "id_rsa", "id_ed25519"}, ScpHelper.preferredPrivateKeyNames(false));
+ }
+
+ /**
+ * Demoted, not dropped: when it is the only key present it still has to be found. wagon-ssh-external
+ * shells out to the host's scp, which does its own cryptography, so the JVM's capabilities say nothing
+ * about whether the key is usable there.
+ */
+ @Test
+ public void ed25519IsStillOfferedWhenTheRuntimeCannotUseIt() {
+ assertTrue(Arrays.asList(ScpHelper.preferredPrivateKeyNames(false)).contains("id_ed25519"));
+ }
+}
diff --git a/wagon-providers/wagon-ssh/pom.xml b/wagon-providers/wagon-ssh/pom.xml
index bbe6a508e..48638218c 100644
--- a/wagon-providers/wagon-ssh/pom.xml
+++ b/wagon-providers/wagon-ssh/pom.xml
@@ -35,19 +35,10 @@ under the License.
null when no agent can be reached or the
+ * one reached holds none, in which case the caller falls back to a key file.
+ *
+ * Three kinds of agent are tried in turn: the OpenSSH agent named by SSH_AUTH_SOCK, the
+ * Win32 OpenSSH agent, and Pageant. Every one of them is optional and each has its own prerequisites --
+ * the first needs a Unix domain socket, which the JDK itself provides only from Java 16, and Pageant
+ * needs JNA, which is not a dependency of this provider. Each is therefore both constructed and used
+ * inside its own guard, so that a missing class is one skipped agent rather than a failure to load.
+ */
+ private IdentityRepository agentIdentityRepository() {
+ IdentityRepository repository = identitiesFrom(() -> new SSHAgentConnector());
+ if (repository == null) {
+ repository = identitiesFrom(() -> new WindowsSSHAgentConnector());
+ }
+ if (repository == null) {
+ repository = identitiesFrom(() -> new PageantConnector());
+ }
+ return repository;
+ }
+
+ /**
+ * Creates one kind of {@link AgentConnector}. The implementations must be written as lambda bodies
+ * rather than as {@code X::new} constructor references: a constructor reference resolves the class when
+ * the reference itself is evaluated, which happens at the call site and so outside the guard in
+ * {@link #identitiesFrom}, letting a {@link NoClassDefFoundError} escape it. A lambda body compiles to a
+ * synthetic method, so the class is not mentioned until that body runs, which is inside the guard.
+ */
+ private interface AgentConnectorFactory {
+ AgentConnector create() throws AgentProxyException;
+ }
+
+ private IdentityRepository identitiesFrom(AgentConnectorFactory factory) {
+ try {
+ AgentConnector connector = factory.create();
+ if (!connector.isAvailable()) {
+ return null;
+ }
+
+ AgentIdentityRepository repository = new AgentIdentityRepository(connector);
+ if (repository.getIdentities().isEmpty()) {
+ fireSessionDebug(connector.getName() + " holds no identities");
+ return null;
+ }
+
+ fireSessionDebug("Using the identities held by " + connector.getName());
+ return repository;
+ } catch (AgentProxyException | RuntimeException | LinkageError e) {
+ fireSessionDebug("Unable to use an SSH agent: " + e);
+ return null;
+ }
+ }
+
public void closeConnection() {
if (session != null) {
session.disconnect();