From 7296ecfe73ed228b3bebeedc2546cb5aa6065e38 Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Sat, 14 Feb 2026 18:56:03 -0800 Subject: [PATCH 1/2] chore(compatibility): port compat tests Bringing over the old compatibility tests and adding some more parameters. --- .../client/AsyncSshCompatibilityTest.kt | 190 ++++++++++++++ .../client/DropbearCompatibilityTest.kt | 242 ++++++++++++++++++ .../test/resources/asyncssh-server/Dockerfile | 27 ++ .../asyncssh-server/requirements.txt | 1 + .../test/resources/asyncssh-server/server.py | 102 ++++++++ .../test/resources/dropbear-server/Dockerfile | 27 ++ .../src/test/resources/dropbear-server/run.sh | 3 + 7 files changed, 592 insertions(+) create mode 100644 sshlib/src/test/kotlin/org/connectbot/sshlib/client/AsyncSshCompatibilityTest.kt create mode 100644 sshlib/src/test/kotlin/org/connectbot/sshlib/client/DropbearCompatibilityTest.kt create mode 100644 sshlib/src/test/resources/asyncssh-server/Dockerfile create mode 100644 sshlib/src/test/resources/asyncssh-server/requirements.txt create mode 100644 sshlib/src/test/resources/asyncssh-server/server.py create mode 100644 sshlib/src/test/resources/dropbear-server/Dockerfile create mode 100644 sshlib/src/test/resources/dropbear-server/run.sh diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/AsyncSshCompatibilityTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/AsyncSshCompatibilityTest.kt new file mode 100644 index 0000000..dddbfd3 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/AsyncSshCompatibilityTest.kt @@ -0,0 +1,190 @@ +/* + * Copyright 2025 Kenny Root + * + * Licensed 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.connectbot.sshlib.client + +import org.connectbot.sshlib.HostKeyVerifier +import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.SshClientConfig +import org.connectbot.sshlib.blocking.BlockingSshClient +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.slf4j.LoggerFactory +import org.testcontainers.containers.GenericContainer +import org.testcontainers.containers.output.Slf4jLogConsumer +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy +import org.testcontainers.images.builder.ImageFromDockerfile +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers + +@Testcontainers +class AsyncSshCompatibilityTest { + + companion object { + private val logger = LoggerFactory.getLogger(AsyncSshCompatibilityTest::class.java) + private val logConsumer = Slf4jLogConsumer(logger).withPrefix("DOCKER") + + private const val USERNAME = "testuser" + private const val PASSWORD = "testpass" + + private val PUBLIC_KEY_NAMES = arrayOf( + "ed25519_unencrypted.pub", + "ecdsa256_unencrypted.pub", + "ecdsa384_unencrypted.pub", + "ecdsa521_unencrypted.pub", + "rsa_unencrypted.pub" + ) + + private val baseImage = ImageFromDockerfile("asyncssh-server-test", false) + .withFileFromClasspath(".", "asyncssh-server").also { image -> + for (key in PUBLIC_KEY_NAMES) { + image.withFileFromClasspath(key, "keys/$key") + } + } + + @Container + @JvmStatic + val server: GenericContainer<*> = GenericContainer(baseImage) + .withExposedPorts(8022) + .withLogConsumer(logConsumer) + .waitingFor(LogMessageWaitStrategy().withRegEx(".*LISTENER READY.*\\s")) + + @JvmStatic + fun encryptionAlgorithms() = listOf( + "chacha20-poly1305@openssh.com", + "aes128-gcm@openssh.com", + "aes256-gcm@openssh.com", + "aes128-ctr", + "aes256-ctr", + "aes128-cbc", + "aes256-cbc", + "3des-cbc" + ) + + @JvmStatic + fun kexAlgorithms() = listOf( + "curve25519-sha256", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + "diffie-hellman-group18-sha512", + "diffie-hellman-group16-sha512", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", + "diffie-hellman-group-exchange-sha1", + "diffie-hellman-group1-sha1" + ) + + @JvmStatic + fun macAlgorithms() = listOf( + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512-etm@openssh.com", + "hmac-sha2-256", + "hmac-sha2-512", + "hmac-sha1-etm@openssh.com", + "hmac-sha1" + ) + + @JvmStatic + fun pubkeyFiles() = listOf( + "ed25519_unencrypted", + "ecdsa256_unencrypted", + "ecdsa384_unencrypted", + "ecdsa521_unencrypted", + "rsa_unencrypted" + ) + } + + private val acceptAllVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean = true + } + + private fun connectToServer(configure: (SshClientConfig.Builder.() -> Unit)? = null): BlockingSshClient { + val host = server.host + val port = server.getMappedPort(8022) + + val config = SshClientConfig { + this.host = host + this.port = port + this.hostKeyVerifier = acceptAllVerifier + configure?.invoke(this) + } + val client = BlockingSshClient(config) + assertTrue(client.connect(), "Should connect to AsyncSSH server") + return client + } + + private fun authenticateWithPassword(configure: (SshClientConfig.Builder.() -> Unit)? = null) { + val client = connectToServer(configure) + try { + assertTrue( + client.authenticatePassword(USERNAME, PASSWORD), + "Should authenticate with password" + ) + } finally { + client.disconnect() + } + } + + @Test + fun `can connect with password`() { + authenticateWithPassword() + } + + @ParameterizedTest(name = "pubkey: {0}") + @MethodSource("pubkeyFiles") + fun `can connect with public key`(keyFilename: String) { + val keyData = javaClass.getResourceAsStream("/keys/$keyFilename")!! + .bufferedReader().readText() + val client = connectToServer() + try { + assertTrue( + client.authenticatePublicKey(USERNAME, keyData), + "Should authenticate with $keyFilename" + ) + } finally { + client.disconnect() + } + } + + @ParameterizedTest(name = "kex: {0}") + @MethodSource("kexAlgorithms") + fun `can connect with kex algorithm`(algorithm: String) { + authenticateWithPassword { + kexAlgorithms = "$algorithm,kex-strict-c-v00@openssh.com" + } + } + + @ParameterizedTest(name = "encryption: {0}") + @MethodSource("encryptionAlgorithms") + fun `can connect with encryption algorithm`(algorithm: String) { + authenticateWithPassword { + encryptionAlgorithms = algorithm + } + } + + @ParameterizedTest(name = "mac: {0}") + @MethodSource("macAlgorithms") + fun `can connect with mac algorithm`(algorithm: String) { + authenticateWithPassword { + encryptionAlgorithms = "aes128-ctr" + macAlgorithms = algorithm + } + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/DropbearCompatibilityTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/DropbearCompatibilityTest.kt new file mode 100644 index 0000000..872bc1d --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/DropbearCompatibilityTest.kt @@ -0,0 +1,242 @@ +/* + * Copyright 2025 Kenny Root + * + * Licensed 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.connectbot.sshlib.client + +import org.connectbot.sshlib.HostKeyVerifier +import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.SshClientConfig +import org.connectbot.sshlib.blocking.BlockingSshClient +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.slf4j.LoggerFactory +import org.testcontainers.containers.GenericContainer +import org.testcontainers.containers.output.Slf4jLogConsumer +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy +import org.testcontainers.images.builder.ImageFromDockerfile +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers + +@Testcontainers +class DropbearCompatibilityTest { + + companion object { + private val logger = LoggerFactory.getLogger(DropbearCompatibilityTest::class.java) + private val logConsumer = Slf4jLogConsumer(logger).withPrefix("DOCKER") + + private const val OPTIONS_ENV = "OPTIONS" + private const val USERNAME = "testuser" + private const val PASSWORD = "testpass" + + private val PUBLIC_KEY_NAMES = arrayOf( + "ed25519_unencrypted.pub", + "ecdsa256_unencrypted.pub", + "ecdsa384_unencrypted.pub", + "ecdsa521_unencrypted.pub", + "rsa_unencrypted.pub" + ) + + private val baseImage = ImageFromDockerfile("dropbear-server-test", false) + .withFileFromClasspath(".", "dropbear-server").also { image -> + for (key in PUBLIC_KEY_NAMES) { + image.withFileFromClasspath(key, "keys/$key") + } + } + + @Container + @JvmStatic + val server: GenericContainer<*> = createBaseContainer() + + @Suppress("resource") + private fun createBaseContainer(): GenericContainer<*> = GenericContainer(baseImage) + .withExposedPorts(22) + .withLogConsumer(logConsumer) + .waitingFor(LogMessageWaitStrategy().withRegEx(".*Not backgrounding.*\\s")) + + @JvmStatic + fun encryptionAlgorithms() = listOf( + "aes128-ctr", + "aes256-ctr", + "chacha20-poly1305@openssh.com" + ) + + @JvmStatic + fun kexAlgorithms() = listOf( + "mlkem768x25519-sha256", + "curve25519-sha256", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + "diffie-hellman-group14-sha256" + ) + + @JvmStatic + fun macAlgorithms() = listOf( + "hmac-sha2-256" + ) + + @JvmStatic + fun pubkeyFiles() = listOf( + "ed25519_unencrypted", + "ecdsa256_unencrypted", + "ecdsa384_unencrypted", + "ecdsa521_unencrypted" + ) + + @JvmStatic + fun hostKeyConfigs() = listOf( + HostKeyConfig("/etc/dropbear/dropbear_ed25519_host_key", "ssh-ed25519"), + HostKeyConfig("/etc/dropbear/dropbear_ecdsa_host_key", "ecdsa-sha2-nistp256"), + HostKeyConfig("/etc/dropbear/dropbear_rsa_host_key", "rsa-sha2-256") + ) + + data class HostKeyConfig(val path: String, val algorithm: String) { + override fun toString(): String = algorithm + } + } + + private val acceptAllVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean = true + } + + private fun connectToServer(configure: (SshClientConfig.Builder.() -> Unit)? = null): BlockingSshClient { + val host = server.host + val port = server.getMappedPort(22) + + val config = SshClientConfig { + this.host = host + this.port = port + this.hostKeyVerifier = acceptAllVerifier + configure?.invoke(this) + } + val client = BlockingSshClient(config) + assertTrue(client.connect(), "Should connect to Dropbear server") + return client + } + + @Suppress("resource") + private fun authenticateWithOptions( + options: String, + configure: (SshClientConfig.Builder.() -> Unit)? = null, + ) { + val customServer = createBaseContainer().withEnv(OPTIONS_ENV, options) + customServer.start() + try { + val host = customServer.host + val port = customServer.getMappedPort(22) + val config = SshClientConfig { + this.host = host + this.port = port + this.hostKeyVerifier = acceptAllVerifier + configure?.invoke(this) + } + val client = BlockingSshClient(config) + try { + assertTrue(client.connect(), "Should connect to Dropbear server with options: $options") + assertTrue( + client.authenticatePassword(USERNAME, PASSWORD), + "Should authenticate with password" + ) + } finally { + client.disconnect() + } + } finally { + customServer.stop() + } + } + + private fun authenticateWithPassword(configure: (SshClientConfig.Builder.() -> Unit)? = null) { + val client = connectToServer(configure) + try { + assertTrue( + client.authenticatePassword(USERNAME, PASSWORD), + "Should authenticate with password" + ) + } finally { + client.disconnect() + } + } + + @Test + fun `can connect with password`() { + authenticateWithPassword() + } + + @Test + fun `wrong password fails`() { + val client = connectToServer() + try { + assertFalse( + client.authenticatePassword(USERNAME, "wrongpassword"), + "Should fail with wrong password" + ) + } finally { + client.disconnect() + } + } + + @ParameterizedTest(name = "pubkey: {0}") + @MethodSource("pubkeyFiles") + fun `can connect with public key`(keyFilename: String) { + val keyData = javaClass.getResourceAsStream("/keys/$keyFilename")!! + .bufferedReader().readText() + val client = connectToServer() + try { + assertTrue( + client.authenticatePublicKey(USERNAME, keyData), + "Should authenticate with $keyFilename" + ) + } finally { + client.disconnect() + } + } + + @ParameterizedTest(name = "hostkey: {0}") + @MethodSource("hostKeyConfigs") + fun `can connect to host with key type`(config: HostKeyConfig) { + authenticateWithOptions("-r ${config.path}") { + hostKeyAlgorithms = config.algorithm + } + } + + @ParameterizedTest(name = "kex: {0}") + @MethodSource("kexAlgorithms") + fun `can connect with kex algorithm`(algorithm: String) { + authenticateWithPassword { + kexAlgorithms = "$algorithm,kex-strict-c-v00@openssh.com" + } + } + + @ParameterizedTest(name = "encryption: {0}") + @MethodSource("encryptionAlgorithms") + fun `can connect with encryption algorithm`(algorithm: String) { + authenticateWithPassword { + encryptionAlgorithms = algorithm + } + } + + @ParameterizedTest(name = "mac: {0}") + @MethodSource("macAlgorithms") + fun `can connect with mac algorithm`(algorithm: String) { + authenticateWithPassword { + encryptionAlgorithms = "aes128-ctr" + macAlgorithms = algorithm + } + } +} diff --git a/sshlib/src/test/resources/asyncssh-server/Dockerfile b/sshlib/src/test/resources/asyncssh-server/Dockerfile new file mode 100644 index 0000000..0202e59 --- /dev/null +++ b/sshlib/src/test/resources/asyncssh-server/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.14-slim-bookworm AS builder +RUN apt-get update && \ + apt-get install -y gcc openssh-server && \ + apt-get clean +WORKDIR /app +COPY requirements.txt /app/requirements.txt +RUN pip install --user -r requirements.txt +RUN mkdir -p /app/etc/ssh && ssh-keygen -A -f /app +COPY . /app + +FROM python:3.14-slim-bookworm AS app +COPY --from=builder /app/etc/ssh /app/etc/ssh +COPY --from=builder /root/.local /app/.local +COPY --from=builder /app/server.py /app/server.py + +USER root +RUN groupadd -g 1234 testserver && \ + useradd -m -u 1234 -g testserver -d /app testserver && \ + chown -R testserver:testserver /app +WORKDIR /app +USER 1234:1234 + +EXPOSE 8022 +ENV PYTHONUNBUFFERED=0 +CMD ["python", "-u", "server.py"] +COPY *.pub /app/ +RUN cat /app/*.pub > /app/authorized_keys && rm -f /app/*.pub diff --git a/sshlib/src/test/resources/asyncssh-server/requirements.txt b/sshlib/src/test/resources/asyncssh-server/requirements.txt new file mode 100644 index 0000000..ee1b53a --- /dev/null +++ b/sshlib/src/test/resources/asyncssh-server/requirements.txt @@ -0,0 +1 @@ +asyncssh[libnacl] diff --git a/sshlib/src/test/resources/asyncssh-server/server.py b/sshlib/src/test/resources/asyncssh-server/server.py new file mode 100644 index 0000000..6bc32ea --- /dev/null +++ b/sshlib/src/test/resources/asyncssh-server/server.py @@ -0,0 +1,102 @@ +import asyncio, asyncssh, sys, logging + +passwords = { + 'testuser': 'testpass' + } + +async def handle_client(process): + process.stdout.write('success\n') + await asyncio.sleep(10) + process.exit(0) + +class MySSHServer(asyncssh.SSHServer): + def __init__(self): + self._conn = None + + def connection_made(self, conn): + print('SSH connection received from %s.' % + conn.get_extra_info('peername')[0]) + self._conn = conn; + + def connection_lost(self, exc): + if exc: + print('SSH connection error: ' + str(exc), file=sys.stderr) + else: + print('SSH connection closed.') + + def begin_auth(self, username): + self._conn.set_authorized_keys('authorized_keys') + return passwords.get(username) != '' + + def password_auth_supported(self): + return True + + def validate_password(self, username, password): + pw = passwords.get(username, '*') + return password == pw + + def public_key_auth_supported(self): + return True + + +async def start_server(): + asyncssh.set_log_level('DEBUG') + asyncssh.set_debug_level(2) + server = await asyncssh.create_server(MySSHServer, '', 8022, + server_host_keys=[ + '/app/etc/ssh/ssh_host_ecdsa_key', + '/app/etc/ssh/ssh_host_rsa_key', + ], + kex_algs=[ + 'curve25519-sha256', + 'curve25519-sha256@libssh.org', + 'ecdh-sha2-nistp256', + 'ecdh-sha2-nistp384', + 'ecdh-sha2-nistp521', + 'diffie-hellman-group-exchange-sha256', + 'diffie-hellman-group-exchange-sha1', + 'diffie-hellman-group18-sha512', + 'diffie-hellman-group16-sha512', + 'diffie-hellman-group14-sha256', + 'diffie-hellman-group14-sha1', + 'diffie-hellman-group1-sha1', + ], + encryption_algs=[ + 'chacha20-poly1305@openssh.com', + 'aes256-gcm@openssh.com', + 'aes128-gcm@openssh.com', + 'aes256-ctr', + 'aes128-ctr', + 'aes256-cbc', + 'aes128-cbc', + '3des-cbc', + ], + mac_algs=[ + 'hmac-sha2-256-etm@openssh.com', + 'hmac-sha2-512-etm@openssh.com', + 'hmac-sha1-etm@openssh.com', + 'hmac-sha2-256', + 'hmac-sha2-512', + 'hmac-sha1', + ], + process_factory=handle_client) + return server + +async def main(): + print("STARTING UP") + + try: + server = await start_server() + except (OSError, asyncssh.Error) as exc: + sys.exit('Error starting server: ' + str(exc)) + + print("LISTENER READY") + + async with server: + await server.wait_closed() + +if __name__ == '__main__': + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nServer shutting down...") diff --git a/sshlib/src/test/resources/dropbear-server/Dockerfile b/sshlib/src/test/resources/dropbear-server/Dockerfile new file mode 100644 index 0000000..cd7d849 --- /dev/null +++ b/sshlib/src/test/resources/dropbear-server/Dockerfile @@ -0,0 +1,27 @@ +FROM alpine:edge +ENV USERNAME testuser +ENV PASSWORD testpass +ENV OPTIONS "" + +RUN adduser -g 'Test User' -s /bin/ash -D $USERNAME && \ + echo "$USERNAME:$PASSWORD" | chpasswd + +COPY run.sh /run.sh +RUN chmod +x run.sh + +EXPOSE 22 +CMD ["/run.sh"] + +COPY *.pub / +RUN mkdir /home/$USERNAME/.ssh && \ + chmod 0700 /home/$USERNAME/.ssh && \ + cat /*.pub > /home/$USERNAME/.ssh/authorized_keys && \ + chmod 0600 /home/$USERNAME/.ssh/authorized_keys && \ + chown -R $USERNAME /home/$USERNAME/.ssh && \ + rm -f /*.pub + +RUN apk add --no-cache dropbear && \ + mkdir -p /etc/dropbear && \ + dropbearkey -t ed25519 -f /etc/dropbear/dropbear_ed25519_host_key && \ + dropbearkey -t ecdsa -f /etc/dropbear/dropbear_ecdsa_host_key && \ + dropbearkey -t rsa -f /etc/dropbear/dropbear_rsa_host_key diff --git a/sshlib/src/test/resources/dropbear-server/run.sh b/sshlib/src/test/resources/dropbear-server/run.sh new file mode 100644 index 0000000..0b11c05 --- /dev/null +++ b/sshlib/src/test/resources/dropbear-server/run.sh @@ -0,0 +1,3 @@ +#!/bin/ash + +exec /usr/sbin/dropbear -EF $OPTIONS From 5206cbb8a46acefe8cb82a8aaceb4b34c190fbcc Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Sat, 14 Feb 2026 19:01:23 -0800 Subject: [PATCH 2/2] chore(README): update features and integration tests --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9246bc7..3ffc691 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ that runs in reaction to state changes. 4419, 5656, 8308, 8709, 8731, 9142) - **Channel I/O**: Interactive shells with PTY, stdout/stderr streams, flow control +- **Port Forwarding**: Local, remote, and dynamic (SOCKS5) port forwarding - **Agent Forwarding**: Forward SSH agent requests with session binding support - **Transport**: Pluggable transport layer (TCP via Ktor, or custom) @@ -60,6 +61,8 @@ that runs in reaction to state changes. - `aes128-gcm@openssh.com` ([draft-miller-sshm-aes-gcm](https://datatracker.ietf.org/doc/html/draft-miller-sshm-aes-gcm)) - `aes256-ctr` ([RFC 4344](https://tools.ietf.org/html/rfc4344#section-4)) - `aes128-ctr` ([RFC 4344](https://tools.ietf.org/html/rfc4344#section-4)) +- `aes256-cbc` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-6.3)) +- `aes128-cbc` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-6.3)) - `3des-cbc` ([RFC 4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.3)) ### MACs @@ -158,9 +161,21 @@ session.requestShell() // When you SSH from bastion to another server, it can request signatures ``` +## Compatibility Testing + +The library is tested against multiple SSH server implementations using Docker +(via Testcontainers): + +- **OpenSSH** 9.9p2 — full integration tests including port forwarding +- **AsyncSSH** (Python) — compatibility tests for ciphers, key exchange, MACs, + and public key auth +- **Dropbear** — compatibility tests including ML-KEM post-quantum key exchange + +Run integration tests with: `./gradlew :sshlib:test` (requires Docker). + ## Current Limitations -- No SFTP or port forwarding +- No SFTP - Client-only (no server implementation) ## License