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..2c74fea 100644 --- a/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy +++ b/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy @@ -1,19 +1,22 @@ package com.rundeck.plugin.util -import com.jcraft.jsch.JSch -import com.jcraft.jsch.JSchException -import com.jcraft.jsch.Session 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.CredentialsProvider import org.eclipse.jgit.transport.SshTransport import org.eclipse.jgit.transport.Transport -import org.eclipse.jgit.util.FS +import org.eclipse.jgit.transport.sshd.ServerKeyDatabase +import org.eclipse.jgit.transport.sshd.SshdSessionFactory + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermissions +import java.security.PublicKey /** - * 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 @@ -22,41 +25,62 @@ class PluginSshSessionFactory extends JschConfigSessionFactory implements Trans } @Override - protected void configure(final OpenSshConfig.Host hc, final Session session) { - if (sshConfig) { - sshConfig.each { k, v -> - session.setConfig(k, v) - } + void configure(final Transport transport) { + if (transport in SshTransport) { + SshTransport sshTransport = (SshTransport) transport + sshTransport.setSshSessionFactory(buildSessionFactory()) } } - @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 + private SshdSessionFactory buildSessionFactory() { + return new CustomSshdSessionFactory(privateKey, sshConfig) } - @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 byte[] privateKey + private final Map sshConfig + private Path cachedKeyFile - @Override - void configure(final Transport transport) { - if (transport instanceof SshTransport) { - SshTransport sshTransport = (SshTransport) transport - sshTransport.setSshSessionFactory(this) + CustomSshdSessionFactory(byte[] privateKey, Map sshConfig) { + super(null, null) + this.privateKey = privateKey + this.sshConfig = sshConfig + } + + @Override + protected List getDefaultIdentities(File sshDir) { + if (privateKey) { + 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 super.getDefaultIdentities(sshDir) + } + + @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) } } } - 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) + } +}