From 646873a06eda47f544abc8a474564725a7d16dff Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sat, 8 Aug 2026 13:01:55 +0200 Subject: [PATCH 1/3] Move wagon-ssh onto the maintained JSch fork com.jcraft:jsch was last released in 2018 and never gained what current servers and current keys need. It has no rsa-sha2-256 or rsa-sha2-512, so RSA public key authentication fails against OpenSSH 8.8 and later, which stopped accepting SHA-1 signatures by default in 2021. It also cannot read the OpenSSH-v1 private key format, which ssh-keygen has produced by default since OpenSSH 7.8. A key generated today is rejected outright, encrypted or not. com.github.mwiede:jsch is a maintained fork of the same code under the same com.jcraft.jsch package, so the provider keeps its API. The fork carries the agent itself, so the two jsch.agentproxy artifacts go. Their ConnectorFactory picked between agent kinds for us; the fork has no equivalent, so the choice is now explicit, and all three kinds the old stack could reach are still tried: the OpenSSH agent named by SSH_AUTH_SOCK, the Win32 OpenSSH agent, and Pageant. One difference is worth knowing: reaching the SSH_AUTH_SOCK agent needs a Unix domain socket, which the JDK provides only from Java 16, and the fork declares no dependencies, so on Java 8 to 15 that agent is out of reach unless a helper library is on the class path. The other two do not need one. An agent holding no identities is now also skipped rather than being installed as an empty repository. Passing a passphrase needs care with the fork. getPrivateKey() substitutes an empty passphrase when the settings name none, which the old JSch quietly ignored, leaving an encrypted key to be unlocked later. The fork rejects it instead, which would have failed every encrypted key. Empty is therefore translated back to none before the key is added. --- wagon-providers/wagon-ssh/pom.xml | 15 +-- .../providers/ssh/jsch/AbstractJschWagon.java | 98 +++++++++++++++---- 2 files changed, 83 insertions(+), 30 deletions(-) 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. - com.jcraft + + com.github.mwiede jsch - 0.1.55 - - - com.jcraft - jsch.agentproxy.connector-factory - 0.0.9 - - - com.jcraft - jsch.agentproxy.jsch - 0.0.9 + 2.28.6 org.codehaus.plexus diff --git a/wagon-providers/wagon-ssh/src/main/java/org/apache/maven/wagon/providers/ssh/jsch/AbstractJschWagon.java b/wagon-providers/wagon-ssh/src/main/java/org/apache/maven/wagon/providers/ssh/jsch/AbstractJschWagon.java index a73209237..987a069bc 100644 --- a/wagon-providers/wagon-ssh/src/main/java/org/apache/maven/wagon/providers/ssh/jsch/AbstractJschWagon.java +++ b/wagon-providers/wagon-ssh/src/main/java/org/apache/maven/wagon/providers/ssh/jsch/AbstractJschWagon.java @@ -32,22 +32,24 @@ import java.util.List; import java.util.Properties; +import com.jcraft.jsch.AgentConnector; +import com.jcraft.jsch.AgentIdentityRepository; +import com.jcraft.jsch.AgentProxyException; import com.jcraft.jsch.ChannelExec; import com.jcraft.jsch.HostKey; import com.jcraft.jsch.HostKeyRepository; import com.jcraft.jsch.IdentityRepository; import com.jcraft.jsch.JSch; import com.jcraft.jsch.JSchException; +import com.jcraft.jsch.PageantConnector; import com.jcraft.jsch.Proxy; import com.jcraft.jsch.ProxyHTTP; import com.jcraft.jsch.ProxySOCKS5; +import com.jcraft.jsch.SSHAgentConnector; import com.jcraft.jsch.Session; import com.jcraft.jsch.UIKeyboardInteractive; import com.jcraft.jsch.UserInfo; -import com.jcraft.jsch.agentproxy.AgentProxyException; -import com.jcraft.jsch.agentproxy.Connector; -import com.jcraft.jsch.agentproxy.ConnectorFactory; -import com.jcraft.jsch.agentproxy.RemoteIdentityRepository; +import com.jcraft.jsch.WindowsSSHAgentConnector; import org.apache.maven.wagon.CommandExecutionException; import org.apache.maven.wagon.CommandExecutor; import org.apache.maven.wagon.ResourceDoesNotExistException; @@ -120,21 +122,11 @@ public void openConnectionInternal() throws AuthenticationException { // can only pick one method of authentication if (privateKey != null && privateKey.exists()) { - fireSessionDebug("Using private key: " + privateKey); - try { - sch.addIdentity(privateKey.getAbsolutePath(), authenticationInfo.getPassphrase()); - } catch (JSchException e) { - throw new AuthenticationException("Cannot connect. Reason: " + e.getMessage(), e); - } + useIdentityFile(sch, privateKey); } else { - try { - Connector connector = ConnectorFactory.getDefault().createConnector(); - if (connector != null) { - IdentityRepository repo = new RemoteIdentityRepository(connector); - sch.setIdentityRepository(repo); - } - } catch (AgentProxyException e) { - fireSessionDebug("Unable to connect to agent: " + e.toString()); + IdentityRepository agent = agentIdentityRepository(); + if (agent != null) { + sch.setIdentityRepository(agent); } } @@ -247,6 +239,76 @@ public void openConnectionInternal() throws AuthenticationException { } } + private void useIdentityFile(JSch sch, File privateKey) throws AuthenticationException { + fireSessionDebug("Using private key: " + privateKey); + + // getPrivateKey() substitutes an empty passphrase when the settings name none, which is + // indistinguishable from "this key is not encrypted". Handing that on rejects every encrypted key + // outright. Passing null instead lets an unencrypted key through and leaves an encrypted one to be + // unlocked when it is used, which is where the user can still be asked for the passphrase. + String passphrase = authenticationInfo.getPassphrase(); + if (passphrase != null && passphrase.isEmpty()) { + passphrase = null; + } + + try { + sch.addIdentity(privateKey.getAbsolutePath(), passphrase); + } catch (JSchException e) { + throw new AuthenticationException("Cannot connect. Reason: " + e.getMessage(), e); + } + } + + /** + * The identities held by a running SSH agent, or 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(SSHAgentConnector::new); + if (repository == null) { + repository = identitiesFrom(WindowsSSHAgentConnector::new); + } + if (repository == null) { + repository = identitiesFrom(PageantConnector::new); + } + return repository; + } + + /** + * Creates one kind of {@link AgentConnector}, in a way that must only be invoked from inside + * {@link #identitiesFrom}: the classes involved may be absent at runtime, so the first mention of one + * has to sit where a {@link LinkageError} is caught. + */ + 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(); From 081ce27ebc9c618a49ff1a119da8474e5ad87769 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sat, 8 Aug 2026 13:02:18 +0200 Subject: [PATCH 2/3] Stop a stray key file in ~/.ssh from shadowing the SSH agent Authentication preferred a key file over the agent whenever one existed: the agent was consulted only in the else branch, and getPrivateKey() falls back to a default key file whenever no password is configured. On any machine with a key in ~/.ssh the agent was therefore never reached, which is most machines, and pointing wagon.privateKeyDirectory at an empty directory was the only way to reach it. Order the methods by how deliberate they are instead: a key named in settings.xml first, then the agent, and a key file that merely happens to be in ~/.ssh only after that. Naming a key keeps the behaviour it has today. This does mean a reachable agent holding identities now takes precedence over a discovered key file. Someone whose agent holds unrelated keys while the key the server wants sits unloaded in ~/.ssh will need to load it, or to name it in settings.xml. Combining the two is not open to us: adding a file identity on top of an agent repository makes JSch push that key into the user's agent, which is not ours to do. While here, look for the key types ssh-keygen actually produces -- id_ed25519, id_ecdsa, id_rsa -- rather than id_dsa followed by id_rsa. Trying id_dsa first reached for the one type OpenSSH has refused by default for years, and the fork disables ssh-dss too. This part is in ScpHelper, so it also changes which key wagon-ssh-external passes to ssh with -i. This fixes WAGON-446, on runtimes where the agent can be reached. --- .../maven/wagon/providers/ssh/ScpHelper.java | 19 ++++++++++++------- .../providers/ssh/jsch/AbstractJschWagon.java | 11 +++++++++-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/wagon-providers/wagon-ssh-common/src/main/java/org/apache/maven/wagon/providers/ssh/ScpHelper.java b/wagon-providers/wagon-ssh-common/src/main/java/org/apache/maven/wagon/providers/ssh/ScpHelper.java index 7859122e9..62c326b1d 100644 --- a/wagon-providers/wagon-ssh-common/src/main/java/org/apache/maven/wagon/providers/ssh/ScpHelper.java +++ b/wagon-providers/wagon-ssh-common/src/main/java/org/apache/maven/wagon/providers/ssh/ScpHelper.java @@ -102,6 +102,13 @@ public static File getPrivateKey(AuthenticationInfo authenticationInfo) throws F return privateKey; } + /** + * The key types 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"}; + private static File findPrivateKey() { String privateKeyDirectory = System.getProperty("wagon.privateKeyDirectory"); @@ -109,16 +116,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 : PRIVATE_KEY_NAMES) { + File privateKey = new File(privateKeyDirectory, ".ssh/" + name); + if (privateKey.exists()) { + return privateKey; } } - return privateKey; + return null; } public static void createZip(List files, File zipName, File basedir) throws IOException { diff --git a/wagon-providers/wagon-ssh/src/main/java/org/apache/maven/wagon/providers/ssh/jsch/AbstractJschWagon.java b/wagon-providers/wagon-ssh/src/main/java/org/apache/maven/wagon/providers/ssh/jsch/AbstractJschWagon.java index 987a069bc..ce4dba013 100644 --- a/wagon-providers/wagon-ssh/src/main/java/org/apache/maven/wagon/providers/ssh/jsch/AbstractJschWagon.java +++ b/wagon-providers/wagon-ssh/src/main/java/org/apache/maven/wagon/providers/ssh/jsch/AbstractJschWagon.java @@ -120,13 +120,20 @@ public void openConnectionInternal() throws AuthenticationException { throw new AuthenticationException(e.getMessage()); } - // can only pick one method of authentication - if (privateKey != null && privateKey.exists()) { + // Can only pick one method of authentication, so pick them in order of how deliberate they are: + // a key named in the settings first, then the agent, and only then a key file that merely happened + // to be lying in ~/.ssh. Letting a found key file outrank the agent means an agent is never reached + // on a machine that has one of those files, which is most of them. + boolean privateKeyConfigured = authenticationInfo.getPrivateKey() != null; + + if (privateKeyConfigured && privateKey != null && privateKey.exists()) { useIdentityFile(sch, privateKey); } else { IdentityRepository agent = agentIdentityRepository(); if (agent != null) { sch.setIdentityRepository(agent); + } else if (privateKey != null && privateKey.exists()) { + useIdentityFile(sch, privateKey); } } From 35ed71efbf94b611aca72be1eb106de8b15c32b9 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sat, 8 Aug 2026 21:08:17 +0200 Subject: [PATCH 3/3] Address the review on the agent guard and the key preference The same two fixes made on the 3.x side in #902. The agent connectors were handed to the guard as constructor references. A constructor reference resolves its class when the reference is evaluated, which happens at the call site -- outside the guard it was meant to be protected by -- so a missing connector class threw NoClassDefFoundError past the catch instead of skipping that agent. Written as lambda bodies the class is not mentioned until the body runs, which is inside the try. Preferring id_ed25519 could also fail a connection outright. The JDK grew EdDSA in Java 15 and this project still targets Java 8, where JSch can only do Ed25519 through a provider such as Bouncy Castle, which is not a dependency here. So a stray id_ed25519 would be chosen and fail even with a usable id_rsa next to it. Ed25519 is now demoted rather than dropped when the runtime cannot use it, so a key that works wins, while an id_ed25519 that is the only key present is still found -- which is what wagon-ssh-external needs, since the host's own scp does the cryptography there. --- .../maven/wagon/providers/ssh/ScpHelper.java | 39 ++++++++++++- .../wagon/providers/ssh/ScpHelperTest.java | 57 +++++++++++++++++++ .../providers/ssh/jsch/AbstractJschWagon.java | 14 +++-- 3 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 wagon-providers/wagon-ssh-common/src/test/java/org/apache/maven/wagon/providers/ssh/ScpHelperTest.java diff --git a/wagon-providers/wagon-ssh-common/src/main/java/org/apache/maven/wagon/providers/ssh/ScpHelper.java b/wagon-providers/wagon-ssh-common/src/main/java/org/apache/maven/wagon/providers/ssh/ScpHelper.java index 62c326b1d..a6c6866b1 100644 --- a/wagon-providers/wagon-ssh-common/src/main/java/org/apache/maven/wagon/providers/ssh/ScpHelper.java +++ b/wagon-providers/wagon-ssh-common/src/main/java/org/apache/maven/wagon/providers/ssh/ScpHelper.java @@ -23,6 +23,8 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -109,6 +111,41 @@ public static File getPrivateKey(AuthenticationInfo authenticationInfo) throws F */ 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"); @@ -116,7 +153,7 @@ private static File findPrivateKey() { privateKeyDirectory = System.getProperty("user.home"); } - for (String name : PRIVATE_KEY_NAMES) { + for (String name : preferredPrivateKeyNames(isEd25519Available())) { File privateKey = new File(privateKeyDirectory, ".ssh/" + name); if (privateKey.exists()) { return privateKey; diff --git a/wagon-providers/wagon-ssh-common/src/test/java/org/apache/maven/wagon/providers/ssh/ScpHelperTest.java b/wagon-providers/wagon-ssh-common/src/test/java/org/apache/maven/wagon/providers/ssh/ScpHelperTest.java new file mode 100644 index 000000000..4ca184a52 --- /dev/null +++ b/wagon-providers/wagon-ssh-common/src/test/java/org/apache/maven/wagon/providers/ssh/ScpHelperTest.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.wagon.providers.ssh; + +import java.util.Arrays; + +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertTrue; + +/** + * The order in which a key file is picked out of ~/.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/src/main/java/org/apache/maven/wagon/providers/ssh/jsch/AbstractJschWagon.java b/wagon-providers/wagon-ssh/src/main/java/org/apache/maven/wagon/providers/ssh/jsch/AbstractJschWagon.java index ce4dba013..4f9c525ce 100644 --- a/wagon-providers/wagon-ssh/src/main/java/org/apache/maven/wagon/providers/ssh/jsch/AbstractJschWagon.java +++ b/wagon-providers/wagon-ssh/src/main/java/org/apache/maven/wagon/providers/ssh/jsch/AbstractJschWagon.java @@ -276,20 +276,22 @@ private void useIdentityFile(JSch sch, File privateKey) throws AuthenticationExc * 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(SSHAgentConnector::new); + IdentityRepository repository = identitiesFrom(() -> new SSHAgentConnector()); if (repository == null) { - repository = identitiesFrom(WindowsSSHAgentConnector::new); + repository = identitiesFrom(() -> new WindowsSSHAgentConnector()); } if (repository == null) { - repository = identitiesFrom(PageantConnector::new); + repository = identitiesFrom(() -> new PageantConnector()); } return repository; } /** - * Creates one kind of {@link AgentConnector}, in a way that must only be invoked from inside - * {@link #identitiesFrom}: the classes involved may be absent at runtime, so the first mention of one - * has to sit where a {@link LinkageError} is caught. + * 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;