diff --git a/sshd-core/src/test/java/org/apache/sshd/common/kex/extension/StrictKexInteroperabilityTest.java b/sshd-core/src/test/java/org/apache/sshd/common/kex/extension/StrictKexInteroperabilityTest.java new file mode 100644 index 000000000..43e6d8a8e --- /dev/null +++ b/sshd-core/src/test/java/org/apache/sshd/common/kex/extension/StrictKexInteroperabilityTest.java @@ -0,0 +1,192 @@ +/* + * 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.sshd.common.kex.extension; + +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.charset.StandardCharsets; + +import org.apache.sshd.client.ClientFactoryManager; +import org.apache.sshd.client.SshClient; +import org.apache.sshd.client.channel.ChannelShell; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.client.session.ClientSessionImpl; +import org.apache.sshd.client.session.SessionFactory; +import org.apache.sshd.common.channel.StreamingChannel; +import org.apache.sshd.common.io.IoSession; +import org.apache.sshd.common.keyprovider.FileKeyPairProvider; +import org.apache.sshd.util.test.BaseTestSupport; +import org.apache.sshd.util.test.CommonTestSupportUtils; +import org.apache.sshd.util.test.ContainerTestCase; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.images.builder.dockerfile.DockerfileBuilder; +import org.testcontainers.utility.MountableFile; + +/** + * Tests to ensure that an Apache MINA sshd client can talk to OpenSSH servers with or without "strict KEX". This + * implicitly tests the message sequence number handling; if sequence numbers get out of sync or are reset wrongly, + * subsequent messages cannot be decrypted correctly and there will be exceptions. + * + * @author Apache MINA SSHD Project + * @see Terrapin Mitigation: "strict-kex" + */ +@Category(ContainerTestCase.class) +public class StrictKexInteroperabilityTest extends BaseTestSupport { + + private static final Logger LOG = LoggerFactory.getLogger(StrictKexInteroperabilityTest.class); + + private static final String TEST_RESOURCES = "org/apache/sshd/common/kex/extensions/client"; + + private SshClient client; + + public StrictKexInteroperabilityTest() { + super(); + } + + @Before + public void setUp() throws Exception { + client = setupTestClient(); + SessionFactory factory = new TestSessionFactory(client); + client.setSessionFactory(factory); + } + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + } + } + + private DockerfileBuilder strictKexImage(DockerfileBuilder builder, boolean withStrictKex) { + if (!withStrictKex) { + return builder + // CentOS 7 is EOL and thus unlikely to get the security update for strict KEX. + .from("centos:7.9.2009") // + .run("yum install -y openssh-server") // Installs OpenSSH 7.4 + .run("/usr/sbin/sshd-keygen") // Generate multiple host keys + .run("adduser bob"); // Add a user + } else { + return builder + .from("alpine:20231219") // + .run("apk --update add openssh-server") // Installs OpenSSH 9.6 + .run("ssh-keygen -A") // Generate multiple host keys + .run("adduser -D bob") // Add a user + .run("echo 'bob:passwordBob' | chpasswd"); // Give it a password to unlock the user + } + } + + @Test + public void testStrictKexOff() throws Exception { + testStrictKex(false); + } + + @Test + public void testStrictKexOn() throws Exception { + testStrictKex(true); + } + + private void testStrictKex(boolean withStrictKex) throws Exception { + // This tests that the message sequence numbers are handled correctly. Strict KEX resets them to zero on any + // KEX, without strict KEX, they're not reset. If sequence numbers get out of sync, received messages are + // decrypted wrongly and there will be exceptions. + @SuppressWarnings("resource") + GenericContainer sshdContainer = new GenericContainer<>(new ImageFromDockerfile() + .withDockerfileFromBuilder(builder -> strictKexImage(builder, withStrictKex) // + .run("mkdir -p /home/bob/.ssh") // Create the SSH config directory + .entryPoint("/entrypoint.sh") // + .build())) // + .withCopyFileToContainer(MountableFile.forClasspathResource(TEST_RESOURCES + "/bob_key.pub"), + "/home/bob/.ssh/authorized_keys") + // entrypoint must be executable. Spotbugs doesn't like 0777, so use hex + .withCopyFileToContainer( + MountableFile.forClasspathResource(TEST_RESOURCES + "/entrypoint.sh", 0x1ff), + "/entrypoint.sh") + .waitingFor(Wait.forLogMessage(".*Server listening on :: port 22.*\\n", 1)) // + .withExposedPorts(22) // + .withLogConsumer(new Slf4jLogConsumer(LOG)); + sshdContainer.start(); + try { + FileKeyPairProvider keyPairProvider = CommonTestSupportUtils.createTestKeyPairProvider(TEST_RESOURCES + "/bob_key"); + client.setKeyIdentityProvider(keyPairProvider); + client.start(); + try (ClientSession session = client.connect("bob", sshdContainer.getHost(), sshdContainer.getMappedPort(22)) + .verify(CONNECT_TIMEOUT).getSession()) { + session.auth().verify(AUTH_TIMEOUT); + assertTrue("Should authenticate", session.isAuthenticated()); + assertTrue("Unexpected session type " + session.getClass().getName(), session instanceof TestSession); + assertEquals("Unexpected strict KEX usage", withStrictKex, ((TestSession) session).usesStrictKex()); + try (ChannelShell channel = session.createShellChannel()) { + channel.setOut(System.out); + channel.setErr(System.err); + channel.setStreaming(StreamingChannel.Streaming.Sync); + PipedOutputStream pos = new PipedOutputStream(); + PipedInputStream pis = new PipedInputStream(pos); + channel.setIn(pis); + assertTrue("Could not open session", channel.open().await(DEFAULT_TIMEOUT)); + LOG.info("writing some data..."); + pos.write("\n\n".getBytes(StandardCharsets.UTF_8)); + assertTrue("Channel should be open", channel.isOpen()); + assertTrue(session.reExchangeKeys().verify(CONNECT_TIMEOUT).isDone()); + assertTrue("Channel should be open", channel.isOpen()); + LOG.info("writing some data..."); + pos.write("\n\n".getBytes(StandardCharsets.UTF_8)); + assertTrue("Channel should be open", channel.isOpen()); + channel.close(true); + } + } + } finally { + sshdContainer.stop(); + } + } + + // Subclass ClientSessionImpl to get access to the strictKex flag. + + private static class TestSessionFactory extends SessionFactory { + + TestSessionFactory(ClientFactoryManager client) { + super(client); + } + + @Override + protected ClientSessionImpl doCreateSession(IoSession ioSession) throws Exception { + return new TestSession(getClient(), ioSession); + } + } + + private static class TestSession extends ClientSessionImpl { + + TestSession(ClientFactoryManager client, IoSession ioSession) throws Exception { + super(client, ioSession); + } + + boolean usesStrictKex() { + return strictKex; + } + } +} diff --git a/sshd-core/src/test/resources/org/apache/sshd/common/kex/extensions/client/bob_key b/sshd-core/src/test/resources/org/apache/sshd/common/kex/extensions/client/bob_key new file mode 100644 index 000000000..b5b70aeaa --- /dev/null +++ b/sshd-core/src/test/resources/org/apache/sshd/common/kex/extensions/client/bob_key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAxY3Hr1SqpJIQ9SbFfGMGweVy8jg2TEH3GC1K0LudQHJwogRi ++debdCqUtuSITbpPhjkeZSk9rq198d6RhT6TQmY9J8wLL2/+VXZk/rMVEEjeXQS3 +ImRnL2vVmkAunv6LwfDGHIovkhwj3/lqGWphDAKnHyXusPDwQ3N4LFGgxwXvRGqc +lzmP8H+KDWaaPapk1AZCBIoD4JbL8faBtLNU01r+pB3sIKvfsPJ5DxPErThfrPuD +qIbA3axEqFlgX4aVl3yMnSWjfhLhO7xD3YwrtUhannHt8pZQo5FkwCGWDpkG3xs+ +qK3ZACrhMFMTvPuDS83jDtEzNd5KYb4KnkOPMQIDAQABAoIBAQCE5GktgrD/39pU +b25tzFehW25FjpbIGZ/UvbMUUwDnd5RZCMZj9yv1qyc7GOSwFOKmEgpmVqXNuZt9 +dxFBJuT8x7Xf7Zygnp/icbBivakvuTUMMb3X/t6CwfGAwCgcgHMXVZaPYE275f4k +Dq3Wxv7di3NMusGkeY/GcAipF4gmGKKe7Ck1ifRypF2cDJsgTtsoFUHNNKfnT3gf +OcJsVLRl0osbsxdqU+Tep46+jHrNt8J9n2VeRNRIqGHj0CkNdpLQOs+MjvIO3Hgq +9NUxwIExwaPnBpTLlWwfemCz3JQnlAineMbYBGa1tpAA3Iw56NWcNbiOPyUyffbI +wBC4r1uZAoGBAPESsergFD+ontChEI+h38oM/D9DKCObZR2kz6WArZ54i1dJWOgh +HCsuxgPjxmaddPKghfNhUORdZBynuS5G7n6BfItNilDiFm2KBk12d38OVovUFo1Q +r5akclKf0kFxHt5TzHIrNAv7B4OF0Uk3kuDHM7ITX3qDpTSBLlzPAUUHAoGBANHJ +QIPmuF2q+PXnnSgdEyiETfl/IqUTXQyxda8kRIPJKKHZKPHZePhgJKUq9VP32PrP +AxIBNrS3Netsp+EAApj09hmWUcgJRIU1/wjpVGqUmguYgh8nVFOPDudOJD5ltQ/A +enzQ19IkGroaQB8CBGZsPaBAvqRZ5PLbm+BZEPQHAoGAblaMMGCXY/udlQfjOJpy +f1wqKBpoyMNbKJJCqBGZZaruu+jKVJSy++DQqP8b0+PFnzdxl8+24o8MP0FVNKUq +i6RgiLHY2ORiN4ixEctjLjg1zJIqMEv50g06di7IYUORSVk5fhfgHourCLu66rQQ ++eiy9JKBZOXUO4/U1I26mwkCgYAhfuCuLsiBLCtUGAcfwISuk3FfxMzjTpQs0qjX +rhLCd/vk26eN9gs6nR88v/8ryQb8BNGYrljtwdL6I/8qDbZcdcBVlYq5RcGLA3QV +GCxCWDfAYjlkgAMW1GCsze07iUG/ohvskevjwaAC1u4mBUxujhnI3I2T8EZ+AFKD +H7V1QQKBgQDNt+zjSdLtA9AczxDwWmi5SbS+k+nGbi6AQO9i73wky/wxx7FonfWS +2skkOUIst3HBc0Oz+CJTfNFQK6GVqtzTdlZFhMYS0ua1Djd6q6S648+K0cieY4r5 +5irivHYVN8t7lBcvbA7E7yD6dHXSHsn6yOLTrV382qRfJTbxG7ZVWA== +-----END RSA PRIVATE KEY----- diff --git a/sshd-core/src/test/resources/org/apache/sshd/common/kex/extensions/client/bob_key.pub b/sshd-core/src/test/resources/org/apache/sshd/common/kex/extensions/client/bob_key.pub new file mode 100644 index 000000000..efecd1b08 --- /dev/null +++ b/sshd-core/src/test/resources/org/apache/sshd/common/kex/extensions/client/bob_key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFjcevVKqkkhD1JsV8YwbB5XLyODZMQfcYLUrQu51AcnCiBGL515t0KpS25IhNuk+GOR5lKT2urX3x3pGFPpNCZj0nzAsvb/5VdmT+sxUQSN5dBLciZGcva9WaQC6e/ovB8MYcii+SHCPf+WoZamEMAqcfJe6w8PBDc3gsUaDHBe9EapyXOY/wf4oNZpo9qmTUBkIEigPglsvx9oG0s1TTWv6kHewgq9+w8nkPE8StOF+s+4OohsDdrESoWWBfhpWXfIydJaN+EuE7vEPdjCu1SFqece3yllCjkWTAIZYOmQbfGz6ordkAKuEwUxO8+4NLzeMO0TM13kphvgqeQ48x user01 diff --git a/sshd-core/src/test/resources/org/apache/sshd/common/kex/extensions/client/entrypoint.sh b/sshd-core/src/test/resources/org/apache/sshd/common/kex/extensions/client/entrypoint.sh new file mode 100644 index 000000000..26489c5f0 --- /dev/null +++ b/sshd-core/src/test/resources/org/apache/sshd/common/kex/extensions/client/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +chown -R bob /home/bob +chmod 0600 /home/bob/.ssh/* + +/usr/sbin/sshd -D -ddd diff --git a/sshd-mina/pom.xml b/sshd-mina/pom.xml index 967b12930..6d1c4ed6a 100644 --- a/sshd-mina/pom.xml +++ b/sshd-mina/pom.xml @@ -124,6 +124,7 @@ **/SessionReKeyHostKeyExchangeTest.java **/HostBoundPubKeyAuthTest.java **/PortForwardingWithOpenSshTest.java + **/StrictKexInteroperabilityTest.java **/OpenSSHCertificateTest.java diff --git a/sshd-netty/pom.xml b/sshd-netty/pom.xml index 5d774029f..ac34b5094 100644 --- a/sshd-netty/pom.xml +++ b/sshd-netty/pom.xml @@ -143,6 +143,7 @@ **/SessionReKeyHostKeyExchangeTest.java **/HostBoundPubKeyAuthTest.java **/PortForwardingWithOpenSshTest.java + **/StrictKexInteroperabilityTest.java **/OpenSSHCertificateTest.java