diff --git a/src/main/java/com/jcraft/jsch/JSch.java b/src/main/java/com/jcraft/jsch/JSch.java index ae754305..0c86f25c 100644 --- a/src/main/java/com/jcraft/jsch/JSch.java +++ b/src/main/java/com/jcraft/jsch/JSch.java @@ -49,6 +49,8 @@ public class JSch { config.put("require_strict_kex", Util.getSystemProperty("jsch.require_strict_kex", "no")); config.put("enable_server_sig_algs", Util.getSystemProperty("jsch.enable_server_sig_algs", "yes")); + config.put("enable_ext_info_in_auth", + Util.getSystemProperty("jsch.enable_ext_info_in_auth", "yes")); config.put("cipher.s2c", Util.getSystemProperty("jsch.cipher", "aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com")); config.put("cipher.c2s", Util.getSystemProperty("jsch.cipher", diff --git a/src/main/java/com/jcraft/jsch/Session.java b/src/main/java/com/jcraft/jsch/Session.java index ede905e5..57302763 100644 --- a/src/main/java/com/jcraft/jsch/Session.java +++ b/src/main/java/com/jcraft/jsch/Session.java @@ -117,6 +117,10 @@ public class Session { private volatile boolean isConnected = false; + private volatile boolean doExtInfo = false; + private boolean enable_server_sig_algs = true; + private boolean enable_ext_info_in_auth = true; + private volatile boolean initialKex = true; private volatile boolean doStrictKex = false; private boolean enable_strict_kex = true; @@ -314,6 +318,8 @@ public void connect(int connectTimeout) throws JSchException { getLogger().log(Logger.INFO, "Local version string: " + Util.byte2str(V_C)); } + enable_server_sig_algs = getConfig("enable_server_sig_algs").equals("yes"); + enable_ext_info_in_auth = getConfig("enable_ext_info_in_auth").equals("yes"); enable_strict_kex = getConfig("enable_strict_kex").equals("yes"); require_strict_kex = getConfig("require_strict_kex").equals("yes"); send_kexinit(); @@ -376,7 +382,11 @@ public void connect(int connectTimeout) throws JSchException { initialKex = false; } else { in_kex = false; - throw new JSchException("invalid protocol(newkyes): " + buf.getCommand()); + throw new JSchException("invalid protocol(newkeys): " + buf.getCommand()); + } + + if (enable_server_sig_algs && enable_ext_info_in_auth && doExtInfo) { + send_extinfo(); } try { @@ -574,18 +584,27 @@ private KeyExchange receive_kexinit(Buffer buf) throws Exception { } System.arraycopy(buf.buffer, buf.s, I_S, 0, I_S.length); - if ((enable_strict_kex || require_strict_kex) && initialKex) { - doStrictKex = checkServerStrictKex(); - if (doStrictKex) { - if (getLogger().isEnabled(Logger.INFO)) { - getLogger().log(Logger.INFO, "Doing strict KEX"); + if (initialKex) { + if (enable_strict_kex || require_strict_kex) { + doStrictKex = checkServerStrictKex(); + if (doStrictKex) { + if (getLogger().isEnabled(Logger.INFO)) { + getLogger().log(Logger.INFO, "Doing strict KEX"); + } + + if (seqi != 1) { + throw new JSchStrictKexException("KEXINIT not first packet from server"); + } + } else if (require_strict_kex) { + throw new JSchStrictKexException("Strict KEX not supported by server"); } + } - if (seqi != 1) { - throw new JSchStrictKexException("KEXINIT not first packet from server"); + if (enable_server_sig_algs) { + doExtInfo = checkServerExtInfo(); + if (getLogger().isEnabled(Logger.INFO)) { + getLogger().log(Logger.INFO, "ext-info messaging supported by server"); } - } else if (require_strict_kex) { - throw new JSchStrictKexException("Strict KEX not supported by server"); } } @@ -643,6 +662,28 @@ private boolean checkServerStrictKex() { return false; } + private boolean checkServerExtInfo() { + Buffer sb = new Buffer(I_S); + sb.setOffSet(17); + byte[] sp = sb.getString(); // server proposal + + int l = 0; + int m = 0; + while (l < sp.length) { + while (l < sp.length && sp[l] != ',') + l++; + if (m == l) + continue; + if ("ext-info-s".equals(Util.byte2str(sp, m, l - m))) { + return true; + } + l++; + m = l; + } + + return false; + } + private volatile boolean in_kex = false; private volatile boolean in_prompt = false; private volatile String[] not_available_shks = null; @@ -726,8 +767,7 @@ private void send_kexinit() throws Exception { } } - String enable_server_sig_algs = getConfig("enable_server_sig_algs"); - if (enable_server_sig_algs.equals("yes") && !isAuthed) { + if (enable_server_sig_algs && !isAuthed) { kex += ",ext-info-c"; } @@ -862,6 +902,20 @@ private void send_newkeys() throws Exception { } } + private void send_extinfo() throws Exception { + // send SSH_MSG_EXT_INFO(7) + packet.reset(); + buf.putByte((byte) SSH_MSG_EXT_INFO); + buf.putInt(1); + buf.putString(Util.str2byte("ext-info-in-auth@openssh.com")); + buf.putString(Util.str2byte("0")); + write(packet); + + if (getLogger().isEnabled(Logger.INFO)) { + getLogger().log(Logger.INFO, "SSH_MSG_EXT_INFO sent"); + } + } + private void checkHost(String chost, int port, KeyExchange kex) throws JSchException { String shkc = getConfig("StrictHostKeyChecking"); @@ -1298,8 +1352,7 @@ Buffer read(Buffer buf) throws Exception { buf.getInt(); buf.getShort(); boolean ignore = false; - String enable_server_sig_algs = getConfig("enable_server_sig_algs"); - if (!enable_server_sig_algs.equals("yes")) { + if (!enable_server_sig_algs) { ignore = true; if (getLogger().isEnabled(Logger.INFO)) { getLogger().log(Logger.INFO, @@ -2091,6 +2144,8 @@ public void disconnect() { seqo = 0; initialKex = true; doStrictKex = false; + doExtInfo = false; + serverSigAlgs = null; // synchronized(jsch.pool){ // jsch.pool.removeElement(this); @@ -2713,9 +2768,6 @@ public void setConfig(Hashtable newconf) { String key = (newkey.equals("PubkeyAcceptedKeyTypes") ? "PubkeyAcceptedAlgorithms" : newkey); String value = newconf.get(newkey); - if (key.equals("enable_server_sig_algs") && !value.equals("yes")) { - serverSigAlgs = null; - } config.put(key, value); } } @@ -2729,9 +2781,6 @@ public void setConfig(String key, String value) { if (key.equals("PubkeyAcceptedKeyTypes")) { config.put("PubkeyAcceptedAlgorithms", value); } else { - if (key.equals("enable_server_sig_algs") && !value.equals("yes")) { - serverSigAlgs = null; - } config.put(key, value); } } @@ -3148,6 +3197,8 @@ private void applyConfig() throws JSchException { checkConfig(config, "kex"); checkConfig(config, "server_host_key"); checkConfig(config, "prefer_known_host_key_types"); + checkConfig(config, "enable_server_sig_algs"); + checkConfig(config, "enable_ext_info_in_auth"); checkConfig(config, "enable_strict_kex"); checkConfig(config, "require_strict_kex"); checkConfig(config, "enable_pubkey_auth_query"); diff --git a/src/test/java/com/jcraft/jsch/ExtInfoInAuthIT.java b/src/test/java/com/jcraft/jsch/ExtInfoInAuthIT.java new file mode 100644 index 00000000..fb58e2c0 --- /dev/null +++ b/src/test/java/com/jcraft/jsch/ExtInfoInAuthIT.java @@ -0,0 +1,234 @@ +package com.jcraft.jsch; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.github.valfirst.slf4jtest.LoggingEvent; +import com.github.valfirst.slf4jtest.TestLogger; +import com.github.valfirst.slf4jtest.TestLoggerFactory; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Random; +import org.apache.commons.codec.digest.DigestUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +public class ExtInfoInAuthIT { + + private static final int timeout = 2000; + private static final DigestUtils sha256sum = new DigestUtils(DigestUtils.getSha256Digest()); + private static final TestLogger jschLogger = TestLoggerFactory.getTestLogger(JSch.class); + private static final TestLogger sshdLogger = + TestLoggerFactory.getTestLogger(ServerSigAlgsIT.class); + + @TempDir + public Path tmpDir; + private Path in; + private Path out; + private String hash; + private Slf4jLogConsumer sshdLogConsumer; + + @Container + public GenericContainer sshd = new GenericContainer<>( + new ImageFromDockerfile().withFileFromClasspath("ssh_host_rsa_key", "docker/ssh_host_rsa_key") + .withFileFromClasspath("ssh_host_rsa_key.pub", "docker/ssh_host_rsa_key.pub") + .withFileFromClasspath("ssh_host_ecdsa256_key", "docker/ssh_host_ecdsa256_key") + .withFileFromClasspath("ssh_host_ecdsa256_key.pub", "docker/ssh_host_ecdsa256_key.pub") + .withFileFromClasspath("ssh_host_ecdsa384_key", "docker/ssh_host_ecdsa384_key") + .withFileFromClasspath("ssh_host_ecdsa384_key.pub", "docker/ssh_host_ecdsa384_key.pub") + .withFileFromClasspath("ssh_host_ecdsa521_key", "docker/ssh_host_ecdsa521_key") + .withFileFromClasspath("ssh_host_ecdsa521_key.pub", "docker/ssh_host_ecdsa521_key.pub") + .withFileFromClasspath("ssh_host_ed25519_key", "docker/ssh_host_ed25519_key") + .withFileFromClasspath("ssh_host_ed25519_key.pub", "docker/ssh_host_ed25519_key.pub") + .withFileFromClasspath("ssh_host_dsa_key", "docker/ssh_host_dsa_key") + .withFileFromClasspath("ssh_host_dsa_key.pub", "docker/ssh_host_dsa_key.pub") + .withFileFromClasspath("sshd_config", "docker/sshd_config.ExtInfoInAuthIT") + .withFileFromClasspath("authorized_keys", "docker/authorized_keys") + .withFileFromClasspath("Dockerfile", "docker/Dockerfile.ExtInfoInAuthIT")) + .withExposedPorts(22); + + @BeforeAll + public static void beforeAll() { + JSch.setLogger(new Slf4jLogger()); + } + + @BeforeEach + public void beforeEach() throws IOException { + if (sshdLogConsumer == null) { + sshdLogConsumer = new Slf4jLogConsumer(sshdLogger); + sshd.followOutput(sshdLogConsumer); + } + + in = tmpDir.resolve("in"); + out = tmpDir.resolve("out"); + Files.createFile(in); + try (OutputStream os = Files.newOutputStream(in)) { + byte[] data = new byte[1024]; + for (int i = 0; i < 1024 * 100; i += 1024) { + new Random().nextBytes(data); + os.write(data); + } + } + hash = sha256sum.digestAsHex(in); + + jschLogger.clearAll(); + sshdLogger.clearAll(); + } + + @AfterAll + public static void afterAll() { + JSch.setLogger(null); + jschLogger.clearAll(); + sshdLogger.clearAll(); + } + + @Test + public void testExtInfoInAuthYes() throws Exception { + JSch ssh = createRSAIdentity(); + Session session = createSession(ssh, "rsa"); + session.setConfig("enable_ext_info_in_auth", "yes"); + session.setConfig("PubkeyAcceptedAlgorithms", "ssh-rsa"); + doSftp(session, "rsa", true); + + String expectedServerKex = "server proposal: KEX algorithms: .*,ext-info-s,.*"; + String expectedClientKex = "client proposal: KEX algorithms: .*,ext-info-c,.*"; + String expected1 = "ext-info messaging supported by server"; + String expected2 = "SSH_MSG_EXT_INFO sent"; + String expectedServerSigAlgs1 = "server-sig-algs="; + String expectedServerSigAlgs2 = "server-sig-algs=<.*ssh-rsa.*>"; + String expectedServerSigAlgs3 = "server-sig-algs=<.*ecdsa.*>"; + checkLogs(expectedServerKex); + checkLogs(expectedClientKex); + checkLogs(expected1); + checkLogs(expected2); + checkLogs(expectedServerSigAlgs1); + checkLogs(expectedServerSigAlgs2); + checkNoLogs(expectedServerSigAlgs3); + } + + @Test + public void testExtInfoInAuthNo() throws Exception { + JSch ssh = createRSAIdentity(); + Session session = createSession(ssh, "ecdsa"); + session.setConfig("enable_ext_info_in_auth", "no"); + session.setConfig("PubkeyAcceptedAlgorithms", "ssh-rsa"); + session.setTimeout(timeout); + + assertThrows(JSchException.class, session::connect, "Auth fail for methods 'publickey'"); + + String expectedServerKex = "server proposal: KEX algorithms: .*,ext-info-s,.*"; + String expectedClientKex = "client proposal: KEX algorithms: .*,ext-info-c,.*"; + String expected1 = "ext-info messaging supported by server"; + String expected2 = "SSH_MSG_EXT_INFO sent"; + String expectedServerSigAlgs1 = "server-sig-algs="; + String expectedServerSigAlgs2 = "server-sig-algs=<.*ssh-rsa.*>"; + String expectedServerSigAlgs3 = "server-sig-algs=<.*ecdsa.*>"; + checkLogs(expectedServerKex); + checkLogs(expectedClientKex); + checkLogs(expected1); + checkNoLogs(expected2); + checkLogs(expectedServerSigAlgs1); + checkNoLogs(expectedServerSigAlgs2); + checkNoLogs(expectedServerSigAlgs3); + } + + private JSch createRSAIdentity() throws Exception { + HostKey hostKey = readHostKey(getResourceFile("docker/ssh_host_rsa_key.pub")); + JSch ssh = new JSch(); + ssh.addIdentity(getResourceFile("docker/id_rsa"), getResourceFile("docker/id_rsa.pub"), null); + ssh.getHostKeyRepository().add(hostKey, null); + return ssh; + } + + private HostKey readHostKey(String fileName) throws Exception { + List lines = Files.readAllLines(Paths.get(fileName), UTF_8); + String[] split = lines.get(0).split("\\s+"); + String hostname = + String.format(Locale.ROOT, "[%s]:%d", sshd.getHost(), sshd.getFirstMappedPort()); + return new HostKey(hostname, Base64.getDecoder().decode(split[1])); + } + + private Session createSession(JSch ssh, String username) throws Exception { + Session session = ssh.getSession(username, sshd.getHost(), sshd.getFirstMappedPort()); + session.setConfig("StrictHostKeyChecking", "yes"); + session.setConfig("PreferredAuthentications", "publickey"); + return session; + } + + private void doSftp(Session session, String username, boolean debugException) throws Exception { + String testFile = String.format(Locale.ROOT, "/%s/test", username); + try { + session.setTimeout(timeout); + session.connect(); + ChannelSftp sftp = (ChannelSftp) session.openChannel("sftp"); + sftp.connect(timeout); + sftp.put(in.toString(), testFile); + sftp.get(testFile, out.toString()); + sftp.disconnect(); + session.disconnect(); + } catch (Exception e) { + if (debugException) { + printInfo(); + } + throw e; + } + + assertEquals(1024L * 100L, Files.size(out)); + assertEquals(hash, sha256sum.digestAsHex(out)); + } + + private void printInfo() { + jschLogger.getAllLoggingEvents().stream().map(LoggingEvent::getFormattedMessage) + .forEach(System.out::println); + sshdLogger.getAllLoggingEvents().stream().map(LoggingEvent::getFormattedMessage) + .forEach(System.out::println); + System.out.println(""); + System.out.println(""); + System.out.println(""); + } + + private void checkLogs(String expected) { + Optional actualJsch = jschLogger.getAllLoggingEvents().stream() + .map(LoggingEvent::getFormattedMessage).filter(msg -> msg.matches(expected)).findFirst(); + try { + assertTrue(actualJsch.isPresent(), () -> "JSch: " + expected); + } catch (AssertionError e) { + printInfo(); + throw e; + } + } + + private void checkNoLogs(String expected) { + Optional actualJsch = jschLogger.getAllLoggingEvents().stream() + .map(LoggingEvent::getFormattedMessage).filter(msg -> msg.matches(expected)).findFirst(); + try { + assertFalse(actualJsch.isPresent(), () -> "JSch: " + expected); + } catch (AssertionError e) { + printInfo(); + throw e; + } + } + + private String getResourceFile(String fileName) { + return ResourceUtil.getResourceFile(getClass(), fileName); + } +} diff --git a/src/test/resources/docker/Dockerfile.ExtInfoInAuthIT b/src/test/resources/docker/Dockerfile.ExtInfoInAuthIT new file mode 100644 index 00000000..06a3f9bb --- /dev/null +++ b/src/test/resources/docker/Dockerfile.ExtInfoInAuthIT @@ -0,0 +1,36 @@ +FROM alpine:3.19 +RUN apk update && \ + apk upgrade && \ + apk add openssh && \ + rm /var/cache/apk/* && \ + addgroup -g 1000 rsa && \ + adduser -Du 1000 -G rsa -Hh /rsa -s /bin/sh -g rsa rsa && \ + mkdir -p /rsa/.ssh && \ + chown -R rsa:rsa /rsa && \ + chmod 700 /rsa /rsa/.ssh && \ + passwd -u rsa && \ + addgroup -g 1001 ecdsa && \ + adduser -Du 1001 -G ecdsa -Hh /ecdsa -s /bin/sh -g ecdsa ecdsa && \ + mkdir -p /ecdsa/.ssh && \ + chown -R ecdsa:ecdsa /ecdsa && \ + chmod 700 /ecdsa /ecdsa/.ssh && \ + passwd -u ecdsa +COPY ssh_host_rsa_key /etc/ssh/ +COPY ssh_host_rsa_key.pub /etc/ssh/ +COPY ssh_host_ecdsa256_key /etc/ssh/ +COPY ssh_host_ecdsa256_key.pub /etc/ssh/ +COPY ssh_host_ecdsa384_key /etc/ssh/ +COPY ssh_host_ecdsa384_key.pub /etc/ssh/ +COPY ssh_host_ecdsa521_key /etc/ssh/ +COPY ssh_host_ecdsa521_key.pub /etc/ssh/ +COPY ssh_host_ed25519_key /etc/ssh/ +COPY ssh_host_ed25519_key.pub /etc/ssh/ +COPY ssh_host_dsa_key /etc/ssh/ +COPY ssh_host_dsa_key.pub /etc/ssh/ +COPY sshd_config /etc/ssh/ +COPY authorized_keys /rsa/.ssh/ +COPY authorized_keys /ecdsa/.ssh/ +RUN chown rsa:rsa /rsa/.ssh/authorized_keys && \ + chown ecdsa:ecdsa /ecdsa/.ssh/authorized_keys && \ + chmod 600 /etc/ssh/ssh_*_key /rsa/.ssh/authorized_keys /ecdsa/.ssh/authorized_keys +ENTRYPOINT ["/usr/sbin/sshd", "-D", "-e"] diff --git a/src/test/resources/docker/sshd_config.ExtInfoInAuthIT b/src/test/resources/docker/sshd_config.ExtInfoInAuthIT new file mode 100644 index 00000000..d63c7f83 --- /dev/null +++ b/src/test/resources/docker/sshd_config.ExtInfoInAuthIT @@ -0,0 +1,25 @@ +ChallengeResponseAuthentication no +HostbasedAuthentication no +PasswordAuthentication no +PubkeyAuthentication yes +AuthenticationMethods publickey +PubkeyAcceptedAlgorithms ssh-ed25519 +UseDNS no +PrintMotd no +PermitRootLogin yes +Subsystem sftp internal-sftp +HostKey /etc/ssh/ssh_host_ecdsa256_key +HostKey /etc/ssh/ssh_host_ecdsa384_key +HostKey /etc/ssh/ssh_host_ecdsa521_key +HostKey /etc/ssh/ssh_host_ed25519_key +HostKey /etc/ssh/ssh_host_rsa_key +HostKey /etc/ssh/ssh_host_dsa_key +KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256,diffie-hellman-group18-sha512,diffie-hellman-group16-sha512,diffie-hellman-group14-sha256,diffie-hellman-group-exchange-sha256,diffie-hellman-group-exchange-sha1,diffie-hellman-group14-sha1,diffie-hellman-group1-sha1 +HostKeyAlgorithms ecdsa-sha2-nistp521,ecdsa-sha2-nistp384,ecdsa-sha2-nistp256,ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ssh-rsa,ssh-dss +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr,aes256-cbc,aes192-cbc,aes128-cbc +MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha1-etm@openssh.com,hmac-sha2-512,hmac-sha2-256,hmac-sha1,hmac-sha1-96-etm@openssh.com,hmac-sha1-96,hmac-md5-etm@openssh.com,hmac-md5,hmac-md5-96-etm@openssh.com,hmac-md5-96 +LogLevel DEBUG3 +Match User rsa + PubkeyAcceptedAlgorithms rsa-sha2-512,rsa-sha2-256,ssh-rsa +Match User ecdsa + PubkeyAcceptedAlgorithms ecdsa-sha2-nistp521,ecdsa-sha2-nistp384,ecdsa-sha2-nistp256