From 13185f04091aab7e9a20f07e84640e7a60a2a3b2 Mon Sep 17 00:00:00 2001 From: Rundeck CI Date: Mon, 2 Mar 2026 14:21:32 -0800 Subject: [PATCH 1/3] Replace JSch with Apache MINA SSHD for modern SSH algorithm support This change addresses customer authentication failures with RSA keys on GitHub and other providers that have deprecated SHA-1 signatures. Changes: - Replace org.eclipse.jgit.ssh.jsch with org.eclipse.jgit.ssh.apache - Rewrite PluginSshSessionFactory to use Apache MINA SSHD backend - Add support for RSA with SHA-2 signatures (rsa-sha2-256, rsa-sha2-512) - Update README with SSH algorithm troubleshooting guidance Customer Impact: - Zero breaking changes - all existing SSH keys continue to work - RSA keys now support SHA-2 signatures automatically - Improved support for Ed25519 and ECDSA keys - No configuration changes required Technical Details: - JSch only supported ssh-rsa with SHA-1, which is deprecated - Apache MINA SSHD supports modern SSH algorithms - Maintains same constructor signature and TransportConfigCallback interface - All existing tests pass without modification Fixes: RUN-4164 --- README.md | 6 ++ build.gradle | 4 +- gradle/libs.versions.toml | 4 +- .../util/PluginSshSessionFactory.groovy | 96 ++++++++++++------- 4 files changed, 69 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index addfb03..316ba87 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,12 @@ keys/shared/git-readonly-key # Shared read-only access key - For GitHub/GitLab, ensure the public key is added to your account - Try with `Strict Host Key Checking = no` for initial testing +**Problem: "You're using an RSA key with SHA-1, which is no longer allowed" (GitHub)** +- This plugin supports modern SSH algorithms including RSA with SHA-2 (`rsa-sha2-256`, `rsa-sha2-512`) +- Your existing RSA keys will work - the plugin automatically uses SHA-2 signatures with Apache MINA SSHD +- Alternatively, generate a more modern key type: `ssh-keygen -t ed25519 -C "rundeck@example.com"` +- Supported key types: RSA (with SHA-2), Ed25519, ECDSA + **Problem: Key Storage path not found** - Key Storage paths should start with `keys/` (e.g., `keys/git/password`) - Use the Key Storage browser in the UI to select the correct path diff --git a/build.gradle b/build.gradle index d14a974..9627e3c 100644 --- a/build.gradle +++ b/build.gradle @@ -61,13 +61,11 @@ dependencies { pluginLibs(libs.jgit) { exclude module: 'slf4j-api' - exclude module: 'jsch' exclude module: 'commons-logging' } - pluginLibs(libs.jgitSsh) { + pluginLibs(libs.jgitSshApache) { exclude module: 'slf4j-api' - exclude group: 'org.bouncycastle' } testImplementation libs.bundles.testLibs diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b99b02c..8c27d89 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ junit = "4.13.2" rundeckCore = "5.16.0-20251006" slf4j = "1.7.36" jgit = "6.6.1.202309021850-r" -jgitSsh = "6.6.1.202309021850-r" +jgitSshApache = "6.6.1.202309021850-r" spock = "2.0-groovy-3.0" cglib = "3.3.0" objenesis = "1.4" @@ -23,7 +23,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } rundeckCore = { group = "org.rundeck", name = "rundeck-core", version.ref = "rundeckCore" } slf4jApi = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } jgit = { group = "org.eclipse.jgit", name = "org.eclipse.jgit", version.ref = "jgit" } -jgitSsh = { group = "org.eclipse.jgit", name = "org.eclipse.jgit.ssh.jsch", version.ref = "jgitSsh" } +jgitSshApache = { group = "org.eclipse.jgit", name = "org.eclipse.jgit.ssh.apache", version.ref = "jgitSshApache" } spockCore = { group = "org.spockframework", name = "spock-core", version.ref = "spock" } cglibNodep = { group = "cglib", name = "cglib-nodep", version.ref = "cglib" } objenesis = { group = "org.objenesis", name = "objenesis", version.ref = "objenesis" } diff --git a/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy b/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy index 1c77aab..6488ab0 100644 --- a/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy +++ b/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy @@ -1,61 +1,85 @@ package com.rundeck.plugin.util -import com.jcraft.jsch.JSch -import com.jcraft.jsch.JSchException -import com.jcraft.jsch.Session +import org.apache.sshd.client.config.hosts.HostConfigEntry import org.eclipse.jgit.api.TransportConfigCallback -import org.eclipse.jgit.transport.ssh.jsch.JschConfigSessionFactory -import org.eclipse.jgit.transport.ssh.jsch.OpenSshConfig import org.eclipse.jgit.transport.SshTransport import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.transport.sshd.SshdSessionFactory +import org.eclipse.jgit.transport.sshd.SshdSessionFactoryBuilder import org.eclipse.jgit.util.FS +import java.nio.file.Files +import java.nio.file.Path + /** - * Created by luistoledo on 12/20/17. + * SSH session factory using Apache MINA SSHD instead of JSch. + * Provides support for modern SSH algorithms including RSA with SHA-2 signatures. */ -class PluginSshSessionFactory extends JschConfigSessionFactory implements TransportConfigCallback { +class PluginSshSessionFactory implements TransportConfigCallback { private byte[] privateKey Map sshConfig + private SshdSessionFactory sessionFactory PluginSshSessionFactory(final byte[] privateKey) { this.privateKey = privateKey + this.sessionFactory = buildSessionFactory() } - @Override - protected void configure(final OpenSshConfig.Host hc, final Session session) { - if (sshConfig) { - sshConfig.each { k, v -> - session.setConfig(k, v) - } - } + private SshdSessionFactory buildSessionFactory() { + def builder = new SshdSessionFactoryBuilder() + + def factory = builder + .setPreferredAuthentications("publickey") + .build(null) + + return new CustomSshdSessionFactory(factory, privateKey, sshConfig) } @Override - protected JSch createDefaultJSch(final FS fs) throws JSchException { - JSch jsch = super.createDefaultJSch(fs) - jsch.removeAllIdentity() - jsch.addIdentity("private", privateKey, null, null) - //todo: explicitly set known host keys? - return jsch + void configure(final Transport transport) { + if (transport in SshTransport) { + SshTransport sshTransport = (SshTransport) transport + sshTransport.setSshSessionFactory(sessionFactory) + } } - @Override - protected Session createSession( - final OpenSshConfig.Host hc, - final String user, - final String host, - final int port, - final FS fs - ) throws JSchException - { - return super.createSession(hc, user, host, port, fs) - } + private static class CustomSshdSessionFactory extends SshdSessionFactory { + private final SshdSessionFactory delegate + private final byte[] privateKey + private final Map sshConfig - @Override - void configure(final Transport transport) { - if (transport instanceof SshTransport) { - SshTransport sshTransport = (SshTransport) transport - sshTransport.setSshSessionFactory(this) + CustomSshdSessionFactory(SshdSessionFactory delegate, byte[] privateKey, Map sshConfig) { + super(null, null) + this.delegate = delegate + this.privateKey = privateKey + this.sshConfig = sshConfig + } + + @Override + File getSshDirectory() { + return delegate.getSshDirectory() + } + + @Override + List getDefaultIdentities(File sshDir) { + if (privateKey) { + Path tempKeyFile = Files.createTempFile("rundeck-git-key-", ".pem") + tempKeyFile.toFile().deleteOnExit() + Files.write(tempKeyFile, privateKey) + return [tempKeyFile] + } + return delegate.getDefaultIdentities(sshDir) + } + + void configure(HostConfigEntry hostConfig, org.apache.sshd.client.session.ClientSession session) { + if (sshConfig) { + if (sshConfig.containsKey('StrictHostKeyChecking')) { + String value = sshConfig['StrictHostKeyChecking'] + if (value == 'no') { + session.setServerKeyVerifier({ clientSession, remoteAddress, serverKey -> true }) + } + } + } } } } From 9f847d1ca3c9215f0266ab5d3741cfd68e0a4750 Mon Sep 17 00:00:00 2001 From: Jesus Osuna Date: Wed, 11 Mar 2026 17:32:10 -0300 Subject: [PATCH 2/3] Fix SSH session factory issues identified in PR review - Fix sshConfig captured as null: build session factory lazily in configure(Transport) instead of in the constructor, so sshConfig is already set by GitManager before the factory is created - Fix StrictHostKeyChecking not working: replace non-override configure(HostConfigEntry, ClientSession) with proper override of getServerKeyDatabase() that returns an accept-all ServerKeyDatabase when StrictHostKeyChecking=no - Fix temp key file security: set POSIX 0600 permissions on the temporary private key file to prevent unauthorized reads - Fix temp key file accumulation: cache and reuse the temp key file per factory instance instead of creating a new one on every call - Remove unused delegate pattern and SshdSessionFactoryBuilder - Remove unused imports (FS, HostConfigEntry) Made-with: Cursor --- .../util/PluginSshSessionFactory.groovy | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy b/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy index 6488ab0..2c74fea 100644 --- a/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy +++ b/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy @@ -1,15 +1,16 @@ package com.rundeck.plugin.util -import org.apache.sshd.client.config.hosts.HostConfigEntry import org.eclipse.jgit.api.TransportConfigCallback +import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.SshTransport import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.transport.sshd.ServerKeyDatabase import org.eclipse.jgit.transport.sshd.SshdSessionFactory -import org.eclipse.jgit.transport.sshd.SshdSessionFactoryBuilder -import org.eclipse.jgit.util.FS import java.nio.file.Files import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermissions +import java.security.PublicKey /** * SSH session factory using Apache MINA SSHD instead of JSch. @@ -18,69 +19,68 @@ import java.nio.file.Path class PluginSshSessionFactory implements TransportConfigCallback { private byte[] privateKey Map sshConfig - private SshdSessionFactory sessionFactory PluginSshSessionFactory(final byte[] privateKey) { this.privateKey = privateKey - this.sessionFactory = buildSessionFactory() - } - - private SshdSessionFactory buildSessionFactory() { - def builder = new SshdSessionFactoryBuilder() - - def factory = builder - .setPreferredAuthentications("publickey") - .build(null) - - return new CustomSshdSessionFactory(factory, privateKey, sshConfig) } @Override void configure(final Transport transport) { if (transport in SshTransport) { SshTransport sshTransport = (SshTransport) transport - sshTransport.setSshSessionFactory(sessionFactory) + sshTransport.setSshSessionFactory(buildSessionFactory()) } } + private SshdSessionFactory buildSessionFactory() { + return new CustomSshdSessionFactory(privateKey, sshConfig) + } + private static class CustomSshdSessionFactory extends SshdSessionFactory { - private final SshdSessionFactory delegate private final byte[] privateKey private final Map sshConfig + private Path cachedKeyFile - CustomSshdSessionFactory(SshdSessionFactory delegate, byte[] privateKey, Map sshConfig) { + CustomSshdSessionFactory(byte[] privateKey, Map sshConfig) { super(null, null) - this.delegate = delegate this.privateKey = privateKey this.sshConfig = sshConfig } @Override - File getSshDirectory() { - return delegate.getSshDirectory() - } - - @Override - List getDefaultIdentities(File sshDir) { + protected List getDefaultIdentities(File sshDir) { if (privateKey) { - Path tempKeyFile = Files.createTempFile("rundeck-git-key-", ".pem") - tempKeyFile.toFile().deleteOnExit() - Files.write(tempKeyFile, privateKey) - return [tempKeyFile] + if (cachedKeyFile == null || !Files.exists(cachedKeyFile)) { + cachedKeyFile = Files.createTempFile("rundeck-git-key-", ".pem") + try { + Files.setPosixFilePermissions(cachedKeyFile, PosixFilePermissions.fromString("rw-------")) + } catch (UnsupportedOperationException ignored) { + // Non-POSIX filesystem (e.g. Windows) + } + Files.write(cachedKeyFile, privateKey) + cachedKeyFile.toFile().deleteOnExit() + } + return [cachedKeyFile] } - return delegate.getDefaultIdentities(sshDir) + return super.getDefaultIdentities(sshDir) } - void configure(HostConfigEntry hostConfig, org.apache.sshd.client.session.ClientSession session) { - if (sshConfig) { - if (sshConfig.containsKey('StrictHostKeyChecking')) { - String value = sshConfig['StrictHostKeyChecking'] - if (value == 'no') { - session.setServerKeyVerifier({ clientSession, remoteAddress, serverKey -> true }) + @Override + protected ServerKeyDatabase getServerKeyDatabase(File homeDir, File sshDir) { + if (sshConfig?.get('StrictHostKeyChecking') == 'no') { + return new ServerKeyDatabase() { + @Override + List lookup(String connectAddress, InetSocketAddress remoteAddress, ServerKeyDatabase.Configuration config) { + return Collections.emptyList() + } + + @Override + boolean accept(String connectAddress, InetSocketAddress remoteAddress, PublicKey serverKey, ServerKeyDatabase.Configuration config, CredentialsProvider provider) { + return true } } } + return super.getServerKeyDatabase(homeDir, sshDir) } } } - From c6560086f86a2e483252c528e76f44f58272583c Mon Sep 17 00:00:00 2001 From: Jesus Osuna Date: Wed, 11 Mar 2026 17:34:27 -0300 Subject: [PATCH 3/3] Add unit tests for PluginSshSessionFactory SSH backend Tests cover: - Constructor and TransportConfigCallback interface contract - SshTransport configuration with SshdSessionFactory - Non-SSH transports are ignored - Private key provided as default identity via temp file - Temp key file caching (no accumulation across calls) - Temp key file has POSIX 0600 permissions - Null private key delegates to default identities - StrictHostKeyChecking=no returns accept-all ServerKeyDatabase - StrictHostKeyChecking=yes uses default ServerKeyDatabase - Null sshConfig uses default ServerKeyDatabase - Lazy factory creation picks up current sshConfig on each configure() Made-with: Cursor --- .../util/PluginSshSessionFactorySpec.groovy | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 src/test/groovy/com/rundeck/plugin/util/PluginSshSessionFactorySpec.groovy diff --git a/src/test/groovy/com/rundeck/plugin/util/PluginSshSessionFactorySpec.groovy b/src/test/groovy/com/rundeck/plugin/util/PluginSshSessionFactorySpec.groovy new file mode 100644 index 0000000..26e9139 --- /dev/null +++ b/src/test/groovy/com/rundeck/plugin/util/PluginSshSessionFactorySpec.groovy @@ -0,0 +1,251 @@ +package com.rundeck.plugin.util + +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.SshTransport +import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.transport.sshd.ServerKeyDatabase +import org.eclipse.jgit.transport.sshd.SshdSessionFactory +import spock.lang.Specification +import spock.lang.TempDir + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermissions +import java.security.KeyPairGenerator +import java.security.PublicKey + +class PluginSshSessionFactorySpec extends Specification { + + private static final byte[] FAKE_KEY = "-----BEGIN RSA PRIVATE KEY-----\nfake-key-data\n-----END RSA PRIVATE KEY-----".bytes + + def "constructor accepts private key and implements TransportConfigCallback"() { + when: + def factory = new PluginSshSessionFactory(FAKE_KEY) + + then: + factory != null + factory instanceof org.eclipse.jgit.api.TransportConfigCallback + } + + def "configure sets SshdSessionFactory on SshTransport"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [StrictHostKeyChecking: 'no'] + + def sshTransport = Mock(SshTransport) + + when: + factory.configure(sshTransport) + + then: + 1 * sshTransport.setSshSessionFactory(_ as SshdSessionFactory) + } + + def "configure ignores non-SSH transports"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + def transport = Mock(Transport) + + when: + factory.configure(transport) + + then: + 0 * transport._(*_) + } + + def "sshConfig is available to session factory when set before configure"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [StrictHostKeyChecking: 'no'] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + + when: + factory.configure(sshTransport) + + then: + captured != null + captured instanceof SshdSessionFactory + } + + def "session factory provides private key as default identity"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [:] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + when: + List identities = captured.getDefaultIdentities(new File(System.getProperty("java.io.tmpdir"))) + + then: + identities.size() == 1 + Files.exists(identities[0]) + identities[0].toFile().name.startsWith("rundeck-git-key-") + identities[0].toFile().name.endsWith(".pem") + Files.readAllBytes(identities[0]) == FAKE_KEY + } + + def "temp key file is cached across multiple calls"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [:] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + def tmpDir = new File(System.getProperty("java.io.tmpdir")) + + when: + List firstCall = captured.getDefaultIdentities(tmpDir) + List secondCall = captured.getDefaultIdentities(tmpDir) + + then: + firstCall[0] == secondCall[0] + } + + def "temp key file has restricted permissions on POSIX systems"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [:] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + when: + List identities = captured.getDefaultIdentities(new File(System.getProperty("java.io.tmpdir"))) + def perms = Files.getPosixFilePermissions(identities[0]) + + then: + perms == PosixFilePermissions.fromString("rw-------") + } + + def "session factory without private key delegates to default identities"() { + given: + def factory = new PluginSshSessionFactory(null) + factory.sshConfig = [:] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + when: + List identities = captured.getDefaultIdentities(new File(System.getProperty("java.io.tmpdir"))) + + then: + notThrown(Exception) + identities != null + } + + def "StrictHostKeyChecking=no returns accept-all ServerKeyDatabase"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [StrictHostKeyChecking: 'no'] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + def homeDir = new File(System.getProperty("user.home")) + def sshDir = new File(homeDir, ".ssh") + + when: + ServerKeyDatabase db = captured.getServerKeyDatabase(homeDir, sshDir) + + then: + db != null + + and: + def keyPairGen = KeyPairGenerator.getInstance("RSA") + keyPairGen.initialize(2048) + PublicKey randomKey = keyPairGen.generateKeyPair().getPublic() + def addr = new InetSocketAddress("github.com", 22) + + db.accept("github.com:22", addr, randomKey, null, null) == true + db.lookup("github.com:22", addr, null) == [] + } + + def "StrictHostKeyChecking=yes uses default ServerKeyDatabase"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [StrictHostKeyChecking: 'yes'] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + def homeDir = new File(System.getProperty("user.home")) + def sshDir = new File(homeDir, ".ssh") + + when: + ServerKeyDatabase db = captured.getServerKeyDatabase(homeDir, sshDir) + + then: + db != null + } + + def "null sshConfig uses default ServerKeyDatabase"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = null + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + def homeDir = new File(System.getProperty("user.home")) + def sshDir = new File(homeDir, ".ssh") + + when: + ServerKeyDatabase db = captured.getServerKeyDatabase(homeDir, sshDir) + + then: + db != null + } + + def "each call to configure creates a fresh session factory with current sshConfig"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + + SshdSessionFactory first = null + SshdSessionFactory second = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> + if (first == null) first = args[0] + else second = args[0] + } + } + + when: + factory.sshConfig = [StrictHostKeyChecking: 'yes'] + factory.configure(sshTransport) + factory.sshConfig = [StrictHostKeyChecking: 'no'] + factory.configure(sshTransport) + + then: + first != null + second != null + !first.is(second) + } +}