Skip to content

Move wagon-ssh to the maintained JSch fork and stop a stray key shadowing the agent - #903

Open
slachiewicz wants to merge 3 commits into
apache:masterfrom
slachiewicz:ssh-jsch-fork-and-auth-order
Open

Move wagon-ssh to the maintained JSch fork and stop a stray key shadowing the agent#903
slachiewicz wants to merge 3 commits into
apache:masterfrom
slachiewicz:ssh-jsch-fork-and-auth-order

Conversation

@slachiewicz

Copy link
Copy Markdown
Member

Same change as #902, on master. The cherry-pick was clean.

Two commits, separable: the first swaps the SSH library, the second fixes an authentication bug that is independent of it.

Fixes #901. Fixes #503. Carries forward #680.

1. Move to the maintained JSch fork

com.jcraft:jsch:0.1.55 was last released in 2018. The provider is not merely unmaintained, it is already broken against current keys — here is 0.1.55 against two keys ssh-keygen produced with defaults on this machine:

jsch-0.1.55        enc_key   -> JSchException: invalid privatekey
                   plain_key -> JSchException: invalid privatekey
com.github.mwiede  enc_key   -> OK
                   plain_key -> OK

It cannot read the OpenSSH-v1 format ssh-keygen has emitted by default since OpenSSH 7.8, encrypted or not, and it has no rsa-sha2-*, so RSA authentication fails against OpenSSH 8.8 and later anyway.

com.github.mwiede:jsch:2.28.6 keeps the com.jcraft.jsch package, so the API is unchanged. Its base bytecode is Java 8.

Two things needed care rather than a straight coordinate swap:

The agent. The jsch.agentproxy artifacts have no counterpart, and their ConnectorFactory used to pick between agent kinds. The choice is now explicit and still covers all three the old stack could reach — SSH_AUTH_SOCK, Win32 OpenSSH, Pageant. One capability does narrow: 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–15 that particular agent is out of reach without a helper library on the class path. The other two need nothing. An agent holding no identities is now skipped rather than installed as an empty repository.

Passphrases. getPrivateKey() substitutes an empty passphrase when the settings name none. The old JSch discarded the result of setPassphrase, so an encrypted key stayed encrypted and was unlocked later; the fork rejects it instead. Left alone, that would have failed every encrypted key. Empty is now translated back to none before the key is added:

enc_key,   settings gave no passphrase -> accepted
plain_key, settings gave no passphrase -> accepted
enc_key,   correct passphrase          -> accepted
enc_key,   wrong passphrase            -> rejected

2. Stop a stray key file shadowing the agent (#503)

The agent was consulted only when no key file was found, and getPrivateKey() finds one whenever no password is configured. On any machine with a key in ~/.ssh the agent was therefore unreachable — which is why the reported workaround was to point wagon.privateKeyDirectory at an empty directory.

The order is now: a key named in settings.xml, then the agent, then a key file that merely happens to be in ~/.ssh. Naming a key keeps today's behaviour.

This is a behaviour change to note in the release notes. A reachable agent holding identities now outranks a discovered key file, so someone whose agent holds unrelated keys while the needed key sits unloaded in ~/.ssh must load it or name it. Combining both is not available to us: adding a file identity on top of an agent repository makes JSch push that key into the user's agent.

Also in this commit, key discovery looks for id_ed25519, id_ecdsa, id_rsa instead of id_dsa then id_rsa. That code is in ScpHelper, so it also changes which key wagon-ssh-external passes to ssh -i.

Verification, and its limits

Modules build, spotless:check clean, default test run green.

The change is not verified by the project's own SSH tests, because those do not run. The -Dssh-tests profile is excluded by default, and on the unmodified branch point it gives 112 tests / 94 errors — ComponentLookupException: Unable to lookup component 'org.apache.maven.wagon.Wagon', roleHint: scp, i.e. plexus test wiring rather than anything SSH. My branch gives exactly the same 112 / 94, so this change neither helps nor hurts it, but it means the embedded-MINA-server suite that would genuinely exercise a library swap is unavailable. That harness deserves fixing before this is relied on, and it is worth doing regardless of this PR.

What was verified directly is the key-handling matrix above, run against both libraries.

Not yet done, and worth someone's hands before release: a real site-deploy over scp and sftp against a current OpenSSH, on both Java 11 and Java 17, and one Windows check of the agent path.

@slachiewicz

Copy link
Copy Markdown
Member Author

Updated, and the headline is that this is now verified rather than asserted.

The harness came back. #904 revives the embedded SSH tests, which had not run in over a decade. Running this branch's two commits on top of it: 42 tests, 0 failures against a real embedded MINA sshd server. That replaces the "unverified by the project's own tests" caveat this PR opened with. #904 should go in first. Same change as #902.

It immediately caught a fatal bug in this branch, now fixed. My agent lookup listed the three connector kinds in a static final array:

private static final AgentConnectorFactory[] AGENT_CONNECTORS = {
    SSHAgentConnector::new, WindowsSSHAgentConnector::new, PageantConnector::new
};

PageantConnector needs JNA, and the fork declares no dependencies, so class initialisation of ScpWagon died with NoClassDefFoundError: com/sun/jna/Pointer wrapped in ExceptionInInitializerError. wagon-ssh would not have loaded at all. The default test run never instantiates a provider, so it went unnoticed; the embedded harness instantiates one on the first test and failed 24 of 42.

Each connector is now both constructed and used inside its own guard that catches LinkageError, so a missing class is one skipped agent rather than a failure to load. Pageant therefore still works if JNA is on the class path and is skipped quietly otherwise. Re-run: 42 of 42.

That is a good argument for #904 on its own merits — this defect was invisible to everything else in the build.

@slachiewicz slachiewicz added dependencies Pull requests that update a dependency file enhancement New feature or request labels Aug 8, 2026
@slachiewicz
slachiewicz requested review from michael-o and a lite review from Copilot August 8, 2026 12:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the wagon-ssh provider to use the maintained com.github.mwiede:jsch fork (retaining the com.jcraft.jsch API) and adjusts SSH authentication behavior to prefer explicitly configured keys first, then SSH agents, and only then auto-discovered keys in ~/.ssh. It also updates default private-key discovery to prefer modern key types.

Changes:

  • Swaps wagon-ssh from com.jcraft:jsch + jsch.agentproxy to com.github.mwiede:jsch:2.28.6 and replaces agentproxy integration with the fork’s built-in agent connectors.
  • Changes authentication precedence so an available agent is consulted before a “found” key file in ~/.ssh (while preserving “configured key wins” behavior).
  • Updates private-key discovery to check id_ed25519, id_ecdsa, then id_rsa (dropping id_dsa).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
wagon-providers/wagon-ssh/src/main/java/org/apache/maven/wagon/providers/ssh/jsch/AbstractJschWagon.java Replaces agentproxy usage with fork-native agent connectors and changes auth method precedence; normalizes empty passphrase handling when adding identities.
wagon-providers/wagon-ssh/pom.xml Updates dependency coordinates to the maintained JSch fork and removes agentproxy dependencies.
wagon-providers/wagon-ssh-common/src/main/java/org/apache/maven/wagon/providers/ssh/ScpHelper.java Updates default key discovery order to prefer modern OpenSSH key types.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@slachiewicz

Copy link
Copy Markdown
Member Author

Ordering note, now that #904 has merged to wagon-3.x: the master equivalent of that
harness did not exist, so this PR had nothing to be verified against. #915 forward-ports
it. #915 should go in before this one, mirroring #904 -> #902 on the 3.x side.

Once #915 is in I will rebase this and re-run -Dssh-tests -Dssh-embedded=true so the
master half gets the same 42-test verification the 3.x half got.

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.
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.
The same two fixes made on the 3.x side in apache#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.
@slachiewicz
slachiewicz force-pushed the ssh-jsch-fork-and-auth-order branch from 0d1a96f to 35ed71e Compare August 8, 2026 19:08
@slachiewicz

Copy link
Copy Markdown
Member Author

Rebased onto master and brought over the two fixes from #902, now that #915 has landed and
there is a harness to check them against.

The agent guard. The constructor references defeated it. A constructor reference resolves
its class when the reference is evaluated, which happens at the call site in
agentIdentityRepository() — outside the try in identitiesFrom. So a missing connector
class threw NoClassDefFoundError straight past the catch, which is what the javadoc claimed
could not happen:

methodref: ESCAPED the guard: NoClassDefFoundError
lambda   : caught: NoClassDefFoundError

The key preference. master targets Java 8 as well, and the JDK only grew EdDSA in Java 15;
before that JSch needs a provider such as Bouncy Castle, which is not a dependency. The base
SignatureEdDSA in the JSch jar says so outright — "SignatureEdDSA requires Java15+." — so a
stray id_ed25519 would be selected and fail even with a usable id_rsa beside it. Demoted
rather than skipped, so a lone id_ed25519 is still found; three unit tests cover both
orderings on any JDK.

Verified against the suite this time, which is the whole point of taking #915 first:
-Dssh-tests -Dssh-embedded=true gives 42 tests, 0 failures against a real embedded MINA
sshd, and the full reactor is green.

One note for review: I applied the ScpHelper change by hand rather than copying the file from
the 3.x branch. Copying it dragged in three things master had already cleaned up — IOUtil.close
in place of try-with-resources, some resurrected commented-out executeCommand lines, and a
reverted comment typo fix. The diff here is the Ed25519 change and nothing else.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Upgrade wagon-ssh to the maintained JSch fork (com.github.mwiede:jsch) [WAGON-446] SSH agent authentication is no longer working in wagon-ssh 2.10

2 participants