From f5ec94e84e2d94ae82799666ff1f85a099bac06f Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Wed, 6 May 2026 20:00:23 -0700 Subject: [PATCH 1/4] chore: add tests for JavaMlKemProvider This requires adding Java 25 to the test matrix. --- .github/workflows/ci.yml | 2 +- .../sshlib/crypto/JavaMlKemProvider.kt | 45 ++-- .../sshlib/crypto/JavaMlKemProviderTest.kt | 207 ++++++++++++++++++ 3 files changed, 232 insertions(+), 22 deletions(-) create mode 100644 sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/JavaMlKemProviderTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10d1177a..b44e1996 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: timeout-minutes: 30 strategy: matrix: - java: [17, 21] + java: [17, 21, 25] steps: - name: Checkout (with history) uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/JavaMlKemProvider.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/JavaMlKemProvider.kt index 6f025587..c56f64f1 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/JavaMlKemProvider.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/JavaMlKemProvider.kt @@ -39,6 +39,30 @@ internal class JavaMlKemProvider : MlKemProvider { 0x03, 0x82.toByte(), 0x04, 0xa1.toByte(), // BIT STRING 0x00, // no unused bits ) + + internal fun extractRawMlKemPublicKey(x509Encoded: ByteArray): ByteArray { + if (x509Encoded.size < X509_PREFIX.size) throw IOException("X.509 encoded ML-KEM public key too short") + if (x509Encoded[0] != 0x30.toByte()) throw IOException("Invalid X.509 encoding: expected SEQUENCE tag") + if (x509Encoded[17] != 0x03.toByte()) throw IOException("Invalid X.509 encoding: BIT STRING not found") + if (x509Encoded[21] != 0x00.toByte()) throw IOException("Invalid X.509 encoding: unexpected unused bits") + if (x509Encoded.size < X509_PREFIX.size + MLKEM768_PUBLIC_KEY_SIZE) { + throw IOException("X.509 encoded ML-KEM public key missing raw key bytes") + } + + val rawKey = ByteArray(MLKEM768_PUBLIC_KEY_SIZE) + System.arraycopy(x509Encoded, X509_PREFIX.size, rawKey, 0, MLKEM768_PUBLIC_KEY_SIZE) + return rawKey + } + + internal fun wrapRawMlKemPublicKey(rawKey: ByteArray): ByteArray { + if (rawKey.size != MLKEM768_PUBLIC_KEY_SIZE) { + throw IOException("Invalid raw ML-KEM public key size: ${rawKey.size}") + } + val x509 = ByteArray(X509_PREFIX.size + MLKEM768_PUBLIC_KEY_SIZE) + System.arraycopy(X509_PREFIX, 0, x509, 0, X509_PREFIX.size) + System.arraycopy(rawKey, 0, x509, X509_PREFIX.size, MLKEM768_PUBLIC_KEY_SIZE) + return x509 + } } private val kemInstance: Any @@ -114,25 +138,4 @@ internal class JavaMlKemProvider : MlKemProvider { throw IOException("ML-KEM decapsulation failed", e) } } - - private fun extractRawMlKemPublicKey(x509Encoded: ByteArray): ByteArray { - if (x509Encoded.size < 22) throw IOException("X.509 encoded ML-KEM public key too short") - if (x509Encoded[0] != 0x30.toByte()) throw IOException("Invalid X.509 encoding: expected SEQUENCE tag") - if (x509Encoded[17] != 0x03.toByte()) throw IOException("Invalid X.509 encoding: BIT STRING not found") - if (x509Encoded[21] != 0x00.toByte()) throw IOException("Invalid X.509 encoding: unexpected unused bits") - - val rawKey = ByteArray(MLKEM768_PUBLIC_KEY_SIZE) - System.arraycopy(x509Encoded, 22, rawKey, 0, MLKEM768_PUBLIC_KEY_SIZE) - return rawKey - } - - private fun wrapRawMlKemPublicKey(rawKey: ByteArray): ByteArray { - if (rawKey.size != MLKEM768_PUBLIC_KEY_SIZE) { - throw IOException("Invalid raw ML-KEM public key size: ${rawKey.size}") - } - val x509 = ByteArray(X509_PREFIX.size + MLKEM768_PUBLIC_KEY_SIZE) - System.arraycopy(X509_PREFIX, 0, x509, 0, X509_PREFIX.size) - System.arraycopy(rawKey, 0, x509, X509_PREFIX.size, MLKEM768_PUBLIC_KEY_SIZE) - return x509 - } } diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/JavaMlKemProviderTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/JavaMlKemProviderTest.kt new file mode 100644 index 00000000..f7acb4d7 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/JavaMlKemProviderTest.kt @@ -0,0 +1,207 @@ +/* + * 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.crypto + +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import java.io.IOException +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class JavaMlKemProviderTest { + + @Test + fun `constructor reports initialization failure as IOException when Java KEM is unavailable`() { + val result = runCatching { JavaMlKemProvider() } + assumeTrue(result.isFailure, "Java ML-KEM is available on this runtime") + + val exception = result.exceptionOrNull() + assertIs(exception) + assertEquals("Failed to initialize Java KEM API", exception.message) + assertTrue(exception.cause is Exception) + } + + @Test + fun `generateKeyPair returns raw public key and encoded private key`() { + val provider = nativeProvider() + + val keyPair = provider.generateKeyPair() + + assertEquals(1184, keyPair.publicKey.size) + assertTrue(keyPair.privateKey.isNotEmpty()) + } + + @Test + fun `encapsulate and decapsulate agree on shared secret`() { + val provider = nativeProvider() + val keyPair = provider.generateKeyPair() + + val encapsulation = provider.encapsulate(keyPair.publicKey) + val decapsulated = provider.decapsulate(keyPair.privateKey, encapsulation.ciphertext) + + assertEquals(1088, encapsulation.ciphertext.size) + assertEquals(32, encapsulation.sharedSecret.size) + assertContentEquals(encapsulation.sharedSecret, decapsulated) + } + + @Test + fun `generateKeyPair returns different keys each time`() { + val provider = nativeProvider() + + val first = provider.generateKeyPair() + val second = provider.generateKeyPair() + + assertTrue(!first.publicKey.contentEquals(second.publicKey)) + assertTrue(!first.privateKey.contentEquals(second.privateKey)) + } + + @Test + fun `wrapRawMlKemPublicKey rejects keys with wrong sizes`() { + assertIOException("Invalid raw ML-KEM public key size: 1183") { + JavaMlKemProvider.wrapRawMlKemPublicKey(ByteArray(1183)) + } + assertIOException("Invalid raw ML-KEM public key size: 1185") { + JavaMlKemProvider.wrapRawMlKemPublicKey(ByteArray(1185)) + } + } + + @Test + fun `wrapRawMlKemPublicKey returns x509 encoded ML-KEM key`() { + val rawKey = ByteArray(1184) { (it % 251).toByte() } + + val x509 = JavaMlKemProvider.wrapRawMlKemPublicKey(rawKey) + + assertEquals(1206, x509.size) + assertContentEquals( + byteArrayOf( + 0x30, + 0x82.toByte(), + 0x04, + 0xb2.toByte(), + 0x30, + 0x0b, + 0x06, + 0x09, + 0x60, + 0x86.toByte(), + 0x48, + 0x01, + 0x65, + 0x03, + 0x04, + 0x04, + 0x02, + 0x03, + 0x82.toByte(), + 0x04, + 0xa1.toByte(), + 0x00, + ), + x509.copyOfRange(0, 22), + ) + assertContentEquals(rawKey, x509.copyOfRange(22, x509.size)) + } + + @Test + fun `extractRawMlKemPublicKey returns raw key bytes from x509 encoding`() { + val rawKey = ByteArray(1184) { ((it * 3) % 251).toByte() } + val x509 = JavaMlKemProvider.wrapRawMlKemPublicKey(rawKey) + + val extracted = JavaMlKemProvider.extractRawMlKemPublicKey(x509) + + assertContentEquals(rawKey, extracted) + } + + @Test + fun `extractRawMlKemPublicKey rejects malformed x509 envelope`() { + assertIOException("X.509 encoded ML-KEM public key too short") { + JavaMlKemProvider.extractRawMlKemPublicKey(ByteArray(21)) + } + + assertIOException("X.509 encoded ML-KEM public key missing raw key bytes") { + JavaMlKemProvider.extractRawMlKemPublicKey(validX509().copyOf(22 + 1183)) + } + + assertIOException("Invalid X.509 encoding: expected SEQUENCE tag") { + JavaMlKemProvider.extractRawMlKemPublicKey(validX509().also { it[0] = 0x31 }) + } + + assertIOException("Invalid X.509 encoding: BIT STRING not found") { + JavaMlKemProvider.extractRawMlKemPublicKey(validX509().also { it[17] = 0x04 }) + } + + assertIOException("Invalid X.509 encoding: unexpected unused bits") { + JavaMlKemProvider.extractRawMlKemPublicKey(validX509().also { it[21] = 0x01 }) + } + } + + @Test + fun `encapsulate wraps invalid raw public key as IOException`() { + val provider = nativeProvider() + + val exception = assertFailsWith { + provider.encapsulate(ByteArray(100)) + } + + assertEquals("ML-KEM encapsulation failed", exception.message) + assertIs(exception.cause) + assertEquals("Invalid raw ML-KEM public key size: 100", exception.cause?.message) + } + + @Test + fun `decapsulate wraps invalid private key as IOException`() { + val provider = nativeProvider() + + val exception = assertFailsWith { + provider.decapsulate(ByteArray(100), ByteArray(1088)) + } + + assertEquals("ML-KEM decapsulation failed", exception.message) + assertTrue(exception.cause is Exception) + } + + @Test + fun `native decapsulate wraps invalid ciphertext as IOException`() { + val provider = nativeProvider() + val keyPair = provider.generateKeyPair() + + val exception = assertFailsWith { + provider.decapsulate(keyPair.privateKey, ByteArray(100)) + } + + assertEquals("ML-KEM decapsulation failed", exception.message) + assertTrue(exception.cause is Exception) + } + + private fun validX509(): ByteArray = JavaMlKemProvider.wrapRawMlKemPublicKey(ByteArray(1184)) + + private fun nativeProvider(): JavaMlKemProvider { + val result = runCatching { JavaMlKemProvider() } + assumeTrue(result.isSuccess, "Java ML-KEM is unavailable: ${result.exceptionOrNull()?.message}") + return result.getOrThrow() + } + + private inline fun assertIOException(expectedMessage: String, block: () -> Unit) { + val exception = assertFailsWith { + block() + } + assertEquals(expectedMessage, exception.message) + } +} From 3bfb22c98e1886f44b8a0d343ed061b50459ba65 Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Wed, 6 May 2026 20:00:53 -0700 Subject: [PATCH 2/4] chore(Sftp): add test coverage for SFTP types --- .../org/connectbot/sshlib/SftpResultTest.kt | 126 ++++++++++++++++++ .../org/connectbot/sshlib/SftpTypesTest.kt | 58 ++++++++ .../client/sftp/SftpFileAttributesTest.kt | 31 +++++ .../sshlib/client/sftp/SftpRawPacketTest.kt | 30 +++++ 4 files changed, 245 insertions(+) create mode 100644 sshlib/src/test/kotlin/org/connectbot/sshlib/SftpResultTest.kt create mode 100644 sshlib/src/test/kotlin/org/connectbot/sshlib/SftpTypesTest.kt create mode 100644 sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpRawPacketTest.kt diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/SftpResultTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/SftpResultTest.kt new file mode 100644 index 00000000..f5a996dd --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/SftpResultTest.kt @@ -0,0 +1,126 @@ +/* + * 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 + +import nl.jqno.equalsverifier.EqualsVerifier +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.fail + +class SftpResultTest { + + @Test + fun `Success equals and hashCode`() { + EqualsVerifier.forClass(SftpResult.Success::class.java) + .verify() + } + + @Test + fun `ServerError equals and hashCode`() { + EqualsVerifier.forClass(SftpResult.ServerError::class.java) + .verify() + } + + @Test + fun `ProtocolError equals and hashCode`() { + EqualsVerifier.forClass(SftpResult.ProtocolError::class.java) + .verify() + } + + @Test + fun `IoError equals and hashCode`() { + EqualsVerifier.forClass(SftpResult.IoError::class.java) + .withPrefabValues( + Throwable::class.java, + IllegalStateException("first"), + IllegalArgumentException("second"), + ) + .verify() + } + + @Test + fun `getOrNull returns value only for success`() { + assertEquals("ok", SftpResult.Success("ok").getOrNull()) + assertNull(SftpResult.ServerError(SftpStatusCode.NO_SUCH_FILE, "missing").getOrNull()) + assertNull(SftpResult.ProtocolError("bad packet").getOrNull()) + assertNull(SftpResult.IoError(IllegalStateException("closed")).getOrNull()) + } + + @Test + fun `getOrThrow returns success value`() { + assertEquals(42, SftpResult.Success(42).getOrThrow()) + } + + @Test + fun `getOrThrow converts server error to SftpException`() { + val exception = expectSftpException { + SftpResult.ServerError(SftpStatusCode.PERMISSION_DENIED, "denied").getOrThrow() + } + + assertEquals(SftpStatusCode.PERMISSION_DENIED, exception.statusCode) + assertEquals("denied", exception.message) + assertNull(exception.cause) + } + + @Test + fun `getOrThrow converts protocol error to bad message`() { + val exception = expectSftpException { + SftpResult.ProtocolError("unexpected packet").getOrThrow() + } + + assertEquals(SftpStatusCode.BAD_MESSAGE, exception.statusCode) + assertEquals("unexpected packet", exception.message) + assertNull(exception.cause) + } + + @Test + fun `getOrThrow converts io error to failure preserving cause`() { + val cause = IllegalStateException("socket closed") + + val exception = expectSftpException { + SftpResult.IoError(cause).getOrThrow() + } + + assertEquals(SftpStatusCode.FAILURE, exception.statusCode) + assertEquals("socket closed", exception.message) + assertSame(cause, exception.cause) + } + + @Test + fun `getOrThrow uses fallback message for io error without message`() { + val cause = object : RuntimeException() {} + + val exception = expectSftpException { + SftpResult.IoError(cause).getOrThrow() + } + + assertEquals(SftpStatusCode.FAILURE, exception.statusCode) + assertEquals("I/O error", exception.message) + assertSame(cause, exception.cause) + } + + private inline fun expectSftpException(block: () -> Unit): SftpException { + try { + block() + } catch (e: SftpException) { + return e + } + fail("Expected SftpException") + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/SftpTypesTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/SftpTypesTest.kt new file mode 100644 index 00000000..b935bfc2 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/SftpTypesTest.kt @@ -0,0 +1,58 @@ +/* + * 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 + +import nl.jqno.equalsverifier.EqualsVerifier +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class SftpTypesTest { + + @Test + fun `SftpFileHandle equals and hashCode`() { + EqualsVerifier.forClass(SftpFileHandle::class.java) + .withPrefabValues(ByteArray::class.java, byteArrayOf(1, 2, 3), byteArrayOf(4, 5, 6)) + .verify() + } + + @Test + fun `SftpAttributes equals and hashCode`() { + EqualsVerifier.forClass(SftpAttributes::class.java) + .verify() + } + + @Test + fun `SftpDirectoryEntry equals and hashCode`() { + EqualsVerifier.forClass(SftpDirectoryEntry::class.java) + .verify() + } + + @Test + fun `SftpStatusCode maps known and unknown status codes`() { + assertEquals(SftpStatusCode.OK, SftpStatusCode.fromCode(0)) + assertEquals(SftpStatusCode.EOF, SftpStatusCode.fromCode(1)) + assertEquals(SftpStatusCode.NO_SUCH_FILE, SftpStatusCode.fromCode(2)) + assertEquals(SftpStatusCode.PERMISSION_DENIED, SftpStatusCode.fromCode(3)) + assertEquals(SftpStatusCode.FAILURE, SftpStatusCode.fromCode(4)) + assertEquals(SftpStatusCode.BAD_MESSAGE, SftpStatusCode.fromCode(5)) + assertEquals(SftpStatusCode.NO_CONNECTION, SftpStatusCode.fromCode(6)) + assertEquals(SftpStatusCode.CONNECTION_LOST, SftpStatusCode.fromCode(7)) + assertEquals(SftpStatusCode.OP_UNSUPPORTED, SftpStatusCode.fromCode(8)) + assertEquals(SftpStatusCode.FAILURE, SftpStatusCode.fromCode(-1)) + assertEquals(SftpStatusCode.FAILURE, SftpStatusCode.fromCode(9)) + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpFileAttributesTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpFileAttributesTest.kt index c52d0984..663cc9f8 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpFileAttributesTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpFileAttributesTest.kt @@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test import java.nio.ByteBuffer import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.text.Charsets.UTF_8 class SftpFileAttributesTest { @@ -172,6 +173,30 @@ class SftpFileAttributesTest { assertNull(attrs.mtime) } + @Test + fun `decode skips extended attributes and consumes only their bytes`() { + val buf = ByteBuffer.allocate(128) + buf.putInt(0x80000001.toInt()) + buf.putLong(1234L) + buf.putInt(2) + buf.putSftpString("vendor@example.com") + buf.putSftpString("alpha") + buf.putSftpString("mtime64") + buf.putSftpString("beta") + buf.flip() + val expectedEnd = buf.limit() + + val attrs = SftpFileAttributes.decode(buf) + + assertEquals(1234L, attrs.size) + assertNull(attrs.uid) + assertNull(attrs.gid) + assertNull(attrs.permissions) + assertNull(attrs.atime) + assertNull(attrs.mtime) + assertEquals(expectedEnd, buf.position()) + } + @Test fun `encoded size for all fields is correct`() { val attrs = SftpAttributes( @@ -186,4 +211,10 @@ class SftpFileAttributesTest { // flags(4) + size(8) + uid+gid(8) + permissions(4) + atime+mtime(8) = 32 assertEquals(32, encoded.size) } + + private fun ByteBuffer.putSftpString(value: String) { + val bytes = value.toByteArray(UTF_8) + putInt(bytes.size) + put(bytes) + } } diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpRawPacketTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpRawPacketTest.kt new file mode 100644 index 00000000..771c02fc --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpRawPacketTest.kt @@ -0,0 +1,30 @@ +/* + * 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.sftp + +import nl.jqno.equalsverifier.EqualsVerifier +import org.junit.jupiter.api.Test + +class SftpRawPacketTest { + + @Test + fun `SftpRawPacket equals and hashCode`() { + EqualsVerifier.forClass(SftpRawPacket::class.java) + .withPrefabValues(ByteArray::class.java, byteArrayOf(1, 2, 3), byteArrayOf(4, 5, 6)) + .verify() + } +} From e527a860cc29ed85208375f1d893b5c4f8b0b093 Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Wed, 6 May 2026 20:34:26 -0700 Subject: [PATCH 3/4] chore: import classes instead of fully-qualified references Import classes when needed instead of referring to them by fully-qualified class name. --- .../connectbot/sshlib/protocol/KaitaiUtils.kt | 5 +++-- .../kotlin/org/connectbot/sshlib/SshKeys.kt | 3 ++- .../sshlib/client/AgentProtocolHandler.kt | 3 ++- .../sshlib/client/DynamicPortForwarder.kt | 4 ++-- .../sshlib/client/LocalPortForwarder.kt | 4 ++-- .../connectbot/sshlib/client/SshConnection.kt | 3 ++- .../connectbot/sshlib/crypto/Base64Compat.kt | 12 ++++++----- .../sshlib/crypto/EcdhKeyExchange.kt | 3 ++- .../sshlib/crypto/EcdsaSignatureAlgorithm.kt | 3 ++- .../crypto/Ed25519SignatureAlgorithm.kt | 3 ++- .../sshlib/crypto/Ed448SignatureAlgorithm.kt | 3 ++- .../sshlib/crypto/JavaMlKemProvider.kt | 8 +++++--- .../sshlib/crypto/OpenSshKeyWriter.kt | 3 ++- .../connectbot/sshlib/crypto/PemKeyReader.kt | 20 ++++++++++++------- .../sshlib/crypto/RsaSignatureAlgorithm.kt | 3 ++- .../sshlib/crypto/SshSignatureAlgorithm.kt | 3 ++- .../crypto/ed25519/Ed25519KeyPairGenerator.kt | 3 ++- .../sshlib/AgentDestinationConstraintTest.kt | 13 +++++++----- .../org/connectbot/sshlib/SshSigningTest.kt | 3 ++- .../connectbot/sshlib/client/FakeSshServer.kt | 11 ++++++---- .../sshlib/client/SshClientIntegrationTest.kt | 3 ++- .../sshlib/crypto/EcdhKeyExchangeTest.kt | 9 ++++++--- .../sshlib/crypto/OpenSshKeyWriterTest.kt | 3 ++- .../sshlib/crypto/PemKeyWriterTest.kt | 3 ++- .../crypto/ed25519/Ed25519KeyFactoryTest.kt | 3 ++- .../sshlib/transport/KtorTcpTransportTest.kt | 3 ++- .../org/connectbot/sshlib/example/Auth.kt | 9 +++++---- .../sshlib/example/SshClientExample.kt | 3 ++- 28 files changed, 94 insertions(+), 55 deletions(-) diff --git a/protocol/src/main/kotlin/org/connectbot/sshlib/protocol/KaitaiUtils.kt b/protocol/src/main/kotlin/org/connectbot/sshlib/protocol/KaitaiUtils.kt index e8bb73a2..9ce8a9b0 100644 --- a/protocol/src/main/kotlin/org/connectbot/sshlib/protocol/KaitaiUtils.kt +++ b/protocol/src/main/kotlin/org/connectbot/sshlib/protocol/KaitaiUtils.kt @@ -18,12 +18,13 @@ package org.connectbot.sshlib.protocol import io.kaitai.struct.ByteBufferKaitaiStream import io.kaitai.struct.KaitaiStruct +import java.nio.BufferOverflowException /** * Serialize a Kaitai struct to a byte array. * * Kaitai's [ByteBufferKaitaiStream] is fixed-capacity, so the underlying - * `ByteBuffer.put` throws [java.nio.BufferOverflowException] if the + * `ByteBuffer.put` throws [BufferOverflowException] if the * pre-allocated buffer is too small. We don't have a cheap way to know * the encoded size up front, so start at 16 KiB and double on overflow * until the message fits or we cross [MAX_BUFFER]. Most SSH messages @@ -40,7 +41,7 @@ fun KaitaiStruct.ReadWrite.toByteArray(): ByteArray { val size = io.pos() io.seek(0) return io.readBytes(size.toLong()) - } catch (_: java.nio.BufferOverflowException) { + } catch (_: BufferOverflowException) { if (capacity >= MAX_BUFFER) throw IllegalStateException("Kaitai message exceeds $MAX_BUFFER byte serialization limit") capacity = minOf(capacity * 2, MAX_BUFFER) } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshKeys.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshKeys.kt index d6785509..f0830048 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshKeys.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshKeys.kt @@ -22,6 +22,7 @@ import org.connectbot.sshlib.crypto.PrivateKeyReader import org.connectbot.sshlib.crypto.ed25519.Ed25519Provider import java.security.KeyFactory import java.security.KeyPair +import java.security.NoSuchAlgorithmException /** * Key management utilities for SSH private keys. @@ -83,7 +84,7 @@ object SshKeys { fun ensureEd25519Support() { try { KeyFactory.getInstance("Ed25519") - } catch (_: java.security.NoSuchAlgorithmException) { + } catch (_: NoSuchAlgorithmException) { Ed25519Provider.insertIfNeeded() } } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentProtocolHandler.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentProtocolHandler.kt index b4f15c37..3de119ed 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentProtocolHandler.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentProtocolHandler.kt @@ -17,6 +17,7 @@ package org.connectbot.sshlib.client import io.kaitai.struct.ByteBufferKaitaiStream +import io.kaitai.struct.KaitaiStream import io.kaitai.struct.KaitaiStruct import org.connectbot.sshlib.AgentIdentity import org.connectbot.sshlib.AgentKeySpec @@ -340,7 +341,7 @@ internal class AgentProtocolHandler( private inline fun parsePayload(message: SshAgentMessage): T { val stream = ByteBufferKaitaiStream(message._raw_payload()) - val payload = T::class.java.getConstructor(io.kaitai.struct.KaitaiStream::class.java).newInstance(stream) + val payload = T::class.java.getConstructor(KaitaiStream::class.java).newInstance(stream) payload._read() return payload } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/DynamicPortForwarder.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/DynamicPortForwarder.kt index 9d4dc3ef..b9cd1848 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/DynamicPortForwarder.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/DynamicPortForwarder.kt @@ -53,7 +53,7 @@ internal class DynamicPortForwarder( ): DynamicPortForwarder { val selectorManager = SelectorManager(Dispatchers.IO) val serverSocket = aSocket(selectorManager).tcp().bind(bindAddress.hostString, bindAddress.port) - val actualAddress = serverSocket.localAddress.toJavaAddress() as java.net.InetSocketAddress + val actualAddress = serverSocket.localAddress.toJavaAddress() as InetSocketAddress val handler = Socks5Handler(authenticator) val forwarder = DynamicPortForwarder( @@ -108,7 +108,7 @@ internal class DynamicPortForwarder( return } - val remoteAddr = socket.remoteAddress.toJavaAddress() as? java.net.InetSocketAddress + val remoteAddr = socket.remoteAddress.toJavaAddress() as? InetSocketAddress val originAddr = remoteAddr?.hostString ?: "127.0.0.1" val originPort = remoteAddr?.port ?: 0 diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/LocalPortForwarder.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/LocalPortForwarder.kt index 53ba389e..6450e2ab 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/LocalPortForwarder.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/LocalPortForwarder.kt @@ -54,7 +54,7 @@ internal class LocalPortForwarder( ): LocalPortForwarder { val selectorManager = SelectorManager(Dispatchers.IO) val serverSocket = aSocket(selectorManager).tcp().bind(bindAddress.hostString, bindAddress.port) - val actualAddress = serverSocket.localAddress.toJavaAddress() as java.net.InetSocketAddress + val actualAddress = serverSocket.localAddress.toJavaAddress() as InetSocketAddress val forwarder = LocalPortForwarder( scope, @@ -92,7 +92,7 @@ internal class LocalPortForwarder( } private suspend fun handleConnection(socket: Socket) { - val remoteAddr = socket.remoteAddress.toJavaAddress() as? java.net.InetSocketAddress + val remoteAddr = socket.remoteAddress.toJavaAddress() as? InetSocketAddress val originAddr = remoteAddr?.hostString ?: "127.0.0.1" val originPort = remoteAddr?.port ?: 0 diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt index ce96cd03..1641062d 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt @@ -17,6 +17,7 @@ package org.connectbot.sshlib.client import io.kaitai.struct.ByteBufferKaitaiStream +import io.kaitai.struct.KaitaiStream import io.kaitai.struct.KaitaiStruct import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CloseableCoroutineDispatcher @@ -2399,7 +2400,7 @@ class SshConnection( private inline fun parseBody(packet: UnencryptedPacket.UnencryptedPayload): T { val rawBody = packet._raw_body() val stream = ByteBufferKaitaiStream(rawBody) - val msg = T::class.java.getConstructor(io.kaitai.struct.KaitaiStream::class.java).newInstance(stream) + val msg = T::class.java.getConstructor(KaitaiStream::class.java).newInstance(stream) msg._read() return msg } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Base64Compat.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Base64Compat.kt index 76f65e77..1d1aa256 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Base64Compat.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Base64Compat.kt @@ -16,12 +16,14 @@ package org.connectbot.sshlib.crypto +import java.util.Base64 + /** * Base64 compatibility adapter supporting both JVM and Android API 24+. * - * On Android below API 26, `java.util.Base64` is unavailable. This object detects + * On Android below API 26, `Base64` is unavailable. This object detects * `android.util.Base64` at runtime via reflection and delegates to it; otherwise - * falls back to `java.util.Base64`. + * falls back to `Base64`. */ internal object Base64Compat { @@ -45,9 +47,9 @@ internal object Base64Compat { } private class JvmDelegate : Delegate { - private val encoder = java.util.Base64.getEncoder() - private val encoderNoPad = java.util.Base64.getEncoder().withoutPadding() - private val decoder = java.util.Base64.getDecoder() + private val encoder = Base64.getEncoder() + private val encoderNoPad = Base64.getEncoder().withoutPadding() + private val decoder = Base64.getDecoder() override fun encode(data: ByteArray): String = encoderNoPad.encodeToString(data) override fun encodeWithPadding(data: ByteArray): String = encoder.encodeToString(data) diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt index 0ba4bdc1..5a8badb7 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt @@ -20,6 +20,7 @@ import org.connectbot.sshlib.SshException import java.math.BigInteger import java.security.AlgorithmParameters import java.security.KeyFactory +import java.security.KeyPair import java.security.KeyPairGenerator import java.security.interfaces.ECPublicKey import java.security.spec.ECGenParameterSpec @@ -42,7 +43,7 @@ internal class EcdhKeyExchange(private val curveName: String) : KexAlgorithm { private val fieldSize: Int override val hashAlgorithm: String - private var clientKeyPair: java.security.KeyPair? = null + private var clientKeyPair: KeyPair? = null init { val (jcaName, hash) = when (curveName) { diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdsaSignatureAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdsaSignatureAlgorithm.kt index bc882f20..7ac8d32a 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdsaSignatureAlgorithm.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdsaSignatureAlgorithm.kt @@ -24,6 +24,7 @@ import org.connectbot.sshlib.protocol.SshSignature import java.math.BigInteger import java.security.AlgorithmParameters import java.security.KeyFactory +import java.security.PrivateKey import java.security.Signature import java.security.interfaces.ECKey import java.security.spec.ECGenParameterSpec @@ -89,7 +90,7 @@ internal object EcdsaSignatureAlgorithm : SshSignatureAlgorithm { } } - override fun sign(algorithmName: String, privateKey: java.security.PrivateKey, data: ByteArray): ByteArray { + override fun sign(algorithmName: String, privateKey: PrivateKey, data: ByteArray): ByteArray { val ecKey = privateKey as ECKey val fieldSize = (ecKey.params.order.bitLength() + 7) / 8 diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed25519SignatureAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed25519SignatureAlgorithm.kt index 205ec768..284968fa 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed25519SignatureAlgorithm.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed25519SignatureAlgorithm.kt @@ -21,6 +21,7 @@ import org.connectbot.sshlib.protocol.SshEd25519SignatureBlob import org.connectbot.sshlib.protocol.SshPublicKey import org.connectbot.sshlib.protocol.SshSignature import java.security.KeyFactory +import java.security.PrivateKey import java.security.Signature import java.security.spec.X509EncodedKeySpec @@ -49,7 +50,7 @@ internal object Ed25519SignatureAlgorithm : SshSignatureAlgorithm { return verifier.verify(sigBlob.signature().data()) } - override fun sign(algorithmName: String, privateKey: java.security.PrivateKey, data: ByteArray): ByteArray { + override fun sign(algorithmName: String, privateKey: PrivateKey, data: ByteArray): ByteArray { val signer = Signature.getInstance("Ed25519") signer.initSign(privateKey) signer.update(data) diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed448SignatureAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed448SignatureAlgorithm.kt index 26f56406..9a9b007e 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed448SignatureAlgorithm.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed448SignatureAlgorithm.kt @@ -21,6 +21,7 @@ import org.connectbot.sshlib.protocol.SshEd448SignatureBlob import org.connectbot.sshlib.protocol.SshPublicKey import org.connectbot.sshlib.protocol.SshSignature import java.security.KeyFactory +import java.security.PrivateKey import java.security.Signature import java.security.spec.X509EncodedKeySpec @@ -49,7 +50,7 @@ internal object Ed448SignatureAlgorithm : SshSignatureAlgorithm { return verifier.verify(sigBlob.signature().data()) } - override fun sign(algorithmName: String, privateKey: java.security.PrivateKey, data: ByteArray): ByteArray { + override fun sign(algorithmName: String, privateKey: PrivateKey, data: ByteArray): ByteArray { val signer = Signature.getInstance("Ed448") signer.initSign(privateKey) signer.update(data) diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/JavaMlKemProvider.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/JavaMlKemProvider.kt index c56f64f1..f37366bf 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/JavaMlKemProvider.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/JavaMlKemProvider.kt @@ -20,8 +20,10 @@ import java.io.IOException import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.PrivateKey +import java.security.PublicKey import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.X509EncodedKeySpec +import javax.crypto.SecretKey /** * ML-KEM provider using Java 23+ native javax.crypto.KEM API via reflection. @@ -99,7 +101,7 @@ internal class JavaMlKemProvider : MlKemProvider { val pubKey = kf.generatePublic(X509EncodedKeySpec(x509Encoded)) val kemClass = Class.forName("javax.crypto.KEM") - val newEncapsulator = kemClass.getMethod("newEncapsulator", java.security.PublicKey::class.java) + val newEncapsulator = kemClass.getMethod("newEncapsulator", PublicKey::class.java) val encapsulator = newEncapsulator.invoke(kemInstance, pubKey) val encapsulatorClass = Class.forName("javax.crypto.KEM\$Encapsulator") @@ -111,7 +113,7 @@ internal class JavaMlKemProvider : MlKemProvider { val ciphertext = encapsulationMethod.invoke(encapsulated) as ByteArray val keyMethod = encapsulatedClass.getMethod("key") - val secretKey = keyMethod.invoke(encapsulated) as javax.crypto.SecretKey + val secretKey = keyMethod.invoke(encapsulated) as SecretKey val sharedSecret = secretKey.encoded return MlKemEncapsulationResult(ciphertext, sharedSecret) @@ -131,7 +133,7 @@ internal class JavaMlKemProvider : MlKemProvider { val decapsulatorClass = Class.forName("javax.crypto.KEM\$Decapsulator") val decapsulateMethod = decapsulatorClass.getMethod("decapsulate", ByteArray::class.java) - val secretKey = decapsulateMethod.invoke(decapsulator, ciphertext) as javax.crypto.SecretKey + val secretKey = decapsulateMethod.invoke(decapsulator, ciphertext) as SecretKey return secretKey.encoded } catch (e: Exception) { diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/OpenSshKeyWriter.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/OpenSshKeyWriter.kt index da9607c0..3345a79a 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/OpenSshKeyWriter.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/OpenSshKeyWriter.kt @@ -22,6 +22,7 @@ import java.security.KeyPair import java.security.SecureRandom import java.security.interfaces.ECPrivateKey import java.security.interfaces.ECPublicKey +import java.security.interfaces.EdECPrivateKey import java.security.interfaces.RSAPrivateCrtKey import java.security.interfaces.RSAPublicKey @@ -144,7 +145,7 @@ internal object OpenSshKeyWriter { private fun extractEd25519Seed(keyPair: KeyPair): ByteArray { val privKey = keyPair.private return when { - privKey is java.security.interfaces.EdECPrivateKey -> { + privKey is EdECPrivateKey -> { privKey.bytes.orElseThrow { SshException("Cannot extract Ed25519 seed") } } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/PemKeyReader.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/PemKeyReader.kt index f809fe1b..b96f5a42 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/PemKeyReader.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/PemKeyReader.kt @@ -21,6 +21,12 @@ import java.math.BigInteger import java.security.AlgorithmParameters import java.security.KeyFactory import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.PublicKey +import java.security.SecureRandom +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.EdECPrivateKey +import java.security.interfaces.RSAPrivateCrtKey import java.security.spec.ECGenParameterSpec import java.security.spec.ECParameterSpec import java.security.spec.ECPrivateKeySpec @@ -209,7 +215,7 @@ internal object PemKeyReader { try { val kf = KeyFactory.getInstance("Ed25519") val privKey = kf.generatePrivate(PKCS8EncodedKeySpec(data)) - val edPriv = privKey as java.security.interfaces.EdECPrivateKey + val edPriv = privKey as EdECPrivateKey val seed = edPriv.bytes.orElseThrow { SshException("Cannot extract Ed25519 seed") } val pubKey = ed25519PublicKeyFromSeed(seed) return SshPrivateKey("ssh-ed25519", KeyPair(pubKey, privKey), "ssh-ed25519") @@ -217,7 +223,7 @@ internal object PemKeyReader { try { val kf = KeyFactory.getInstance("EC") - val privKey = kf.generatePrivate(PKCS8EncodedKeySpec(data)) as java.security.interfaces.ECPrivateKey + val privKey = kf.generatePrivate(PKCS8EncodedKeySpec(data)) as ECPrivateKey val fieldSize = (privKey.params.order.bitLength() + 7) / 8 val sshAlg = when (fieldSize) { 32 -> "ecdsa-sha2-nistp256" @@ -231,7 +237,7 @@ internal object PemKeyReader { try { val kf = KeyFactory.getInstance("RSA") - val privKey = kf.generatePrivate(PKCS8EncodedKeySpec(data)) as java.security.interfaces.RSAPrivateCrtKey + val privKey = kf.generatePrivate(PKCS8EncodedKeySpec(data)) as RSAPrivateCrtKey val pubSpec = RSAPublicKeySpec(privKey.modulus, privKey.publicExponent) val pubKey = kf.generatePublic(pubSpec) return SshPrivateKey("ssh-rsa", KeyPair(pubKey, privKey), "rsa-sha2-512") @@ -240,7 +246,7 @@ internal object PemKeyReader { throw SshException("Unable to parse PKCS#8 key: unsupported algorithm") } - internal fun ed25519PublicKeyFromSeed(seed: ByteArray): java.security.PublicKey { + internal fun ed25519PublicKeyFromSeed(seed: ByteArray): PublicKey { // Build PKCS#8 from seed, create private key, then use KPG with deterministic random val pkcs8 = encodeDer { sequence { @@ -255,17 +261,17 @@ internal object PemKeyReader { .generatePrivate(PKCS8EncodedKeySpec(pkcs8)) // Use a deterministic SecureRandom that returns our seed - val deterministicRandom = object : java.security.SecureRandom() { + val deterministicRandom = object : SecureRandom() { override fun nextBytes(bytes: ByteArray) { System.arraycopy(seed, 0, bytes, 0, minOf(seed.size, bytes.size)) } } - val kpg = java.security.KeyPairGenerator.getInstance("Ed25519") + val kpg = KeyPairGenerator.getInstance("Ed25519") kpg.initialize(NamedParameterSpec.ED25519, deterministicRandom) return kpg.generateKeyPair().public } - private fun ecPublicKeyFromPkcs8(privKey: java.security.interfaces.ECPrivateKey): java.security.PublicKey { + private fun ecPublicKeyFromPkcs8(privKey: ECPrivateKey): PublicKey { // Parse the PKCS#8 encoding to extract the embedded public key val encoded = privKey.encoded val reader = DerReader(encoded) diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/RsaSignatureAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/RsaSignatureAlgorithm.kt index 12e2cfe7..328c8b9b 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/RsaSignatureAlgorithm.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/RsaSignatureAlgorithm.kt @@ -22,6 +22,7 @@ import org.connectbot.sshlib.protocol.SshRsaSignatureBlob import org.connectbot.sshlib.protocol.SshSignature import java.math.BigInteger import java.security.KeyFactory +import java.security.PrivateKey import java.security.Signature import java.security.spec.RSAPublicKeySpec @@ -42,7 +43,7 @@ internal object RsaSignatureAlgorithm : SshSignatureAlgorithm { return verifier.verify(sigBlob.signature().data()) } - override fun sign(algorithmName: String, privateKey: java.security.PrivateKey, data: ByteArray): ByteArray { + override fun sign(algorithmName: String, privateKey: PrivateKey, data: ByteArray): ByteArray { val jcaAlgorithm = toJcaAlgorithm(algorithmName) val signer = Signature.getInstance(jcaAlgorithm) diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SshSignatureAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SshSignatureAlgorithm.kt index c666e751..30b07986 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SshSignatureAlgorithm.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SshSignatureAlgorithm.kt @@ -18,8 +18,9 @@ package org.connectbot.sshlib.crypto import org.connectbot.sshlib.protocol.SshPublicKey import org.connectbot.sshlib.protocol.SshSignature +import java.security.PrivateKey internal interface SshSignatureAlgorithm { fun verify(pubKey: SshPublicKey, sig: SshSignature, data: ByteArray): Boolean - fun sign(algorithmName: String, privateKey: java.security.PrivateKey, data: ByteArray): ByteArray + fun sign(algorithmName: String, privateKey: PrivateKey, data: ByteArray): ByteArray } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/ed25519/Ed25519KeyPairGenerator.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/ed25519/Ed25519KeyPairGenerator.kt index bb2ada8f..e09021b5 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/ed25519/Ed25519KeyPairGenerator.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/ed25519/Ed25519KeyPairGenerator.kt @@ -17,6 +17,7 @@ package org.connectbot.sshlib.crypto.ed25519 import com.google.crypto.tink.subtle.Ed25519Sign +import java.security.GeneralSecurityException import java.security.KeyPair import java.security.KeyPairGeneratorSpi import java.security.SecureRandom @@ -30,7 +31,7 @@ internal class Ed25519KeyPairGenerator : KeyPairGeneratorSpi() { try { val kp = Ed25519Sign.KeyPair.newKeyPair() return KeyPair(Ed25519PublicKey(kp.publicKey), Ed25519PrivateKey(kp.privateKey)) - } catch (e: java.security.GeneralSecurityException) { + } catch (e: GeneralSecurityException) { throw IllegalStateException(e) } } diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/AgentDestinationConstraintTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/AgentDestinationConstraintTest.kt index 63f1ce5a..63cbc6bd 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/AgentDestinationConstraintTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/AgentDestinationConstraintTest.kt @@ -16,10 +16,12 @@ package org.connectbot.sshlib +import io.kaitai.struct.ByteBufferKaitaiStream import kotlinx.coroutines.test.runTest import org.connectbot.sshlib.client.AgentProtocolHandler import org.connectbot.sshlib.client.AgentSessionInfo import org.connectbot.sshlib.client.SessionBindVerifier +import org.connectbot.sshlib.protocol.SshAgentIdentitiesAnswer import org.connectbot.sshlib.protocol.SshAgentcSessionBind import org.connectbot.sshlib.protocol.SshAgentcSignRequest import org.connectbot.sshlib.protocol.createByteString @@ -27,6 +29,7 @@ import org.connectbot.sshlib.protocol.toByteArray import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import java.nio.ByteBuffer +import java.nio.charset.Charset class AgentDestinationConstraintTest { @@ -43,7 +46,7 @@ class AgentDestinationConstraintTest { return result } - private fun sshString(s: String, charset: java.nio.charset.Charset = Charsets.UTF_8): ByteArray = sshString(s.toByteArray(charset)) + private fun sshString(s: String, charset: Charset = Charsets.UTF_8): ByteArray = sshString(s.toByteArray(charset)) /** * Builds the signed data blob for a publickey or publickey-hostbound auth request. @@ -312,8 +315,8 @@ class AgentDestinationConstraintTest { val (msgType, payload) = parseAgentMessage(response) assertEquals(12, msgType) // SSH_AGENT_IDENTITIES_ANSWER - val stream = io.kaitai.struct.ByteBufferKaitaiStream(payload) - val answer = org.connectbot.sshlib.protocol.SshAgentIdentitiesAnswer(stream) + val stream = ByteBufferKaitaiStream(payload) + val answer = SshAgentIdentitiesAnswer(stream) answer._read() // Only unconstrained key should be visible (constrained key needs fromKeyspecs=hostKeyA @@ -352,8 +355,8 @@ class AgentDestinationConstraintTest { val (msgType, payload) = parseAgentMessage(response) assertEquals(12, msgType) // SSH_AGENT_IDENTITIES_ANSWER - val stream = io.kaitai.struct.ByteBufferKaitaiStream(payload) - val answer = org.connectbot.sshlib.protocol.SshAgentIdentitiesAnswer(stream) + val stream = ByteBufferKaitaiStream(payload) + val answer = SshAgentIdentitiesAnswer(stream) answer._read() // Only unconstrained key should be visible diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/SshSigningTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/SshSigningTest.kt index 682ac2b5..4d451198 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/SshSigningTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/SshSigningTest.kt @@ -24,6 +24,7 @@ import org.connectbot.sshlib.crypto.SignatureVerifier import org.connectbot.sshlib.protocol.SshPublicKey import org.connectbot.sshlib.protocol.SshSignature import org.junit.jupiter.api.Test +import java.security.KeyPair import java.security.KeyPairGenerator import java.security.spec.ECGenParameterSpec import kotlin.test.assertEquals @@ -107,7 +108,7 @@ class SshSigningTest { } } - private fun loadKeyPair(keyResource: String, passphrase: String? = null): java.security.KeyPair { + private fun loadKeyPair(keyResource: String, passphrase: String? = null): KeyPair { val keyData = readKey(keyResource) return PrivateKeyReader.read(keyData, passphrase).jcaKeyPair } diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt index f5666d07..2c1c9050 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt @@ -36,6 +36,7 @@ import org.connectbot.sshlib.crypto.X25519ProviderFactory import org.connectbot.sshlib.crypto.encodeMpint import org.connectbot.sshlib.protocol.SshEnums import org.connectbot.sshlib.protocol.SshMsgExtInfo +import org.connectbot.sshlib.protocol.SshMsgIgnore import org.connectbot.sshlib.protocol.SshMsgKexEcdhInit import org.connectbot.sshlib.protocol.SshMsgKexEcdhReply import org.connectbot.sshlib.protocol.SshMsgKexinit @@ -49,12 +50,14 @@ import org.connectbot.sshlib.protocol.createNameList import org.connectbot.sshlib.protocol.toByteArray import org.connectbot.sshlib.transport.PacketIO import org.connectbot.sshlib.transport.PipedTransport +import java.io.ByteArrayOutputStream import java.math.BigInteger import java.nio.ByteBuffer import java.security.KeyPair import java.security.KeyPairGenerator import java.security.MessageDigest import java.security.SecureRandom +import java.security.Signature import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -101,7 +104,7 @@ class FakeSshServer( fun sendIgnore() { scope.launch(coroutineContext) { - val msg = org.connectbot.sshlib.protocol.SshMsgIgnore().apply { + val msg = SshMsgIgnore().apply { setData(createByteString(byteArrayOf())) _check() } @@ -404,7 +407,7 @@ class FakeSshServer( sharedSecret: ByteArray, ): ByteArray { val md = MessageDigest.getInstance("SHA-256") - val buf = java.io.ByteArrayOutputStream() + val buf = ByteArrayOutputStream() fun writeString(data: ByteArray) { val len = data.size @@ -425,7 +428,7 @@ class FakeSshServer( } private fun signExchangeHash(hash: ByteArray): ByteArray { - val signer = java.security.Signature.getInstance("Ed25519") + val signer = Signature.getInstance("Ed25519") signer.initSign(hostKeyPair.private) signer.update(hash) return signer.sign() @@ -433,7 +436,7 @@ class FakeSshServer( private fun buildSignatureBlob(signature: ByteArray): ByteArray { val algBytes = "ssh-ed25519".toByteArray(Charsets.US_ASCII) - val out = java.io.ByteArrayOutputStream() + val out = ByteArrayOutputStream() out.write(ByteBuffer.allocate(4).putInt(algBytes.size).array()) out.write(algBytes) out.write(ByteBuffer.allocate(4).putInt(signature.size).array()) diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt index f11b544e..1c79a691 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt @@ -51,6 +51,7 @@ import org.testcontainers.containers.wait.strategy.Wait import org.testcontainers.images.builder.ImageFromDockerfile import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Testcontainers +import java.security.MessageDigest import kotlin.test.assertIs /** @@ -74,7 +75,7 @@ class SshClientIntegrationTest { private const val DEBUG_CFLAGS = "" private fun opensshImageName(): String { - val digest = java.security.MessageDigest.getInstance("SHA-256") + val digest = MessageDigest.getInstance("SHA-256") .digest("$OPENSSH_VERSION:$DEBUG_CFLAGS".toByteArray()) val hash = digest.take(4).joinToString("") { "%02x".format(it) } return "openssh-server-test-$hash" diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchangeTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchangeTest.kt index e1e42db8..0ff3763b 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchangeTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchangeTest.kt @@ -19,9 +19,12 @@ package org.connectbot.sshlib.crypto import org.connectbot.sshlib.SshException import org.junit.jupiter.api.Test import java.math.BigInteger +import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.interfaces.ECPublicKey import java.security.spec.ECGenParameterSpec +import java.security.spec.ECPublicKeySpec +import javax.crypto.KeyAgreement import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -92,12 +95,12 @@ class EcdhKeyExchangeTest { val clientSecret = client.computeSharedSecret(qs) // Server computes shared secret from client's Q_C - val serverAgreement = javax.crypto.KeyAgreement.getInstance("ECDH") + val serverAgreement = KeyAgreement.getInstance("ECDH") serverAgreement.init(serverKp.private) val clientPoint = EcdsaSignatureAlgorithm.decodeEcPoint(qc, serverPub.params) - val clientPubKey = java.security.KeyFactory.getInstance("EC") - .generatePublic(java.security.spec.ECPublicKeySpec(clientPoint, serverPub.params)) + val clientPubKey = KeyFactory.getInstance("EC") + .generatePublic(ECPublicKeySpec(clientPoint, serverPub.params)) serverAgreement.doPhase(clientPubKey, true) val serverRawSecret = serverAgreement.generateSecret() diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/OpenSshKeyWriterTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/OpenSshKeyWriterTest.kt index 9c276094..c72dcb15 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/OpenSshKeyWriterTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/OpenSshKeyWriterTest.kt @@ -18,6 +18,7 @@ package org.connectbot.sshlib.crypto import org.connectbot.sshlib.SshException import org.junit.jupiter.api.Test +import java.nio.ByteBuffer import java.security.interfaces.ECPublicKey import java.security.interfaces.RSAPublicKey import java.util.Base64 @@ -155,7 +156,7 @@ class OpenSshKeyWriterTest { private fun extractPrivateSectionLength(pem: String): Int { val binary = extractBinary(pem) - val buf = java.nio.ByteBuffer.wrap(binary) + val buf = ByteBuffer.wrap(binary) fun skipBytes(n: Int) { buf.position(buf.position() + n) diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/PemKeyWriterTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/PemKeyWriterTest.kt index c975eafb..22c1b538 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/PemKeyWriterTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/PemKeyWriterTest.kt @@ -19,6 +19,7 @@ package org.connectbot.sshlib.crypto import org.junit.jupiter.api.Test import java.security.interfaces.ECPublicKey import java.security.interfaces.RSAPublicKey +import java.util.Base64 import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -131,7 +132,7 @@ class PemKeyWriterTest { val der = pem.lines() .filter { !it.startsWith("-----") && it.isNotBlank() } .joinToString("") - .let { java.util.Base64.getDecoder().decode(it) } + .let { Base64.getDecoder().decode(it) } // SEC1 ECPrivateKey ::= SEQUENCE { version INTEGER, privateKey OCTET STRING, // [0] OID OPTIONAL, [1] BIT STRING OPTIONAL } diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/ed25519/Ed25519KeyFactoryTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/ed25519/Ed25519KeyFactoryTest.kt index 56bcff1f..2a6ff049 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/ed25519/Ed25519KeyFactoryTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/ed25519/Ed25519KeyFactoryTest.kt @@ -20,6 +20,7 @@ import org.junit.jupiter.api.Test import java.security.InvalidKeyException import java.security.Key import java.security.KeyFactory +import java.security.KeyPair import java.security.PrivateKey import java.security.PublicKey import java.security.spec.InvalidKeySpecException @@ -37,7 +38,7 @@ class Ed25519KeyFactoryTest { private val factory = KeyFactory.getInstance(Ed25519Provider.KEY_ALGORITHM, provider) private val testSeed = ByteArray(32) { it.toByte() } - private fun generateKeyPair(): java.security.KeyPair = Ed25519KeyPairGenerator().generateKeyPair() + private fun generateKeyPair(): KeyPair = Ed25519KeyPairGenerator().generateKeyPair() @Test fun `generatePublic with X509EncodedKeySpec`() { diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/transport/KtorTcpTransportTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/transport/KtorTcpTransportTest.kt index a532791e..59ec97ef 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/transport/KtorTcpTransportTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/transport/KtorTcpTransportTest.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test +import java.net.Inet4Address import java.net.InetAddress import java.net.InetSocketAddress import kotlin.test.assertEquals @@ -249,7 +250,7 @@ class KtorTcpTransportTest { val transport = KtorTcpTransport("example.com", 22, resolver, factory, IpVersion.IPV4_ONLY) transport.connect() - assertTrue(factory.connectionAttempts.all { it is java.net.Inet4Address }) + assertTrue(factory.connectionAttempts.all { it is Inet4Address }) assertTrue(transport.isConnected) } diff --git a/testapp/src/main/kotlin/org/connectbot/sshlib/example/Auth.kt b/testapp/src/main/kotlin/org/connectbot/sshlib/example/Auth.kt index 7b43c780..7764a4f5 100644 --- a/testapp/src/main/kotlin/org/connectbot/sshlib/example/Auth.kt +++ b/testapp/src/main/kotlin/org/connectbot/sshlib/example/Auth.kt @@ -19,8 +19,9 @@ package org.connectbot.sshlib.example import org.connectbot.sshlib.AuthResult import org.connectbot.sshlib.KeyboardInteractiveCallback import org.connectbot.sshlib.SshClient +import java.io.Console -internal fun readPassphrase(console: java.io.Console?, prompt: String): String = +internal fun readPassphrase(console: Console?, prompt: String): String = if (console != null) { String(console.readPassword(prompt)) } else { @@ -30,7 +31,7 @@ internal fun readPassphrase(console: java.io.Console?, prompt: String): String = } internal class ConsoleKeyboardInteractiveCallback( - private val console: java.io.Console?, + private val console: Console?, ) : KeyboardInteractiveCallback { override suspend fun onInfoRequest( name: String, @@ -59,7 +60,7 @@ internal suspend fun authenticateInteractive(client: SshClient, user: String): B return authenticatePassword(client, user, console) } -private fun readPassword(console: java.io.Console?): String? = +private fun readPassword(console: Console?): String? = if (console != null) { String(console.readPassword("Password: ")) } else { @@ -68,7 +69,7 @@ private fun readPassword(console: java.io.Console?): String? = readlnOrNull() } -internal suspend fun authenticatePassword(client: SshClient, user: String, console: java.io.Console?): Boolean { +internal suspend fun authenticatePassword(client: SshClient, user: String, console: Console?): Boolean { val password = readPassword(console) ?: return false return client.authenticatePassword(user, password) is AuthResult.Success } diff --git a/testapp/src/main/kotlin/org/connectbot/sshlib/example/SshClientExample.kt b/testapp/src/main/kotlin/org/connectbot/sshlib/example/SshClientExample.kt index b26c6173..e628d918 100644 --- a/testapp/src/main/kotlin/org/connectbot/sshlib/example/SshClientExample.kt +++ b/testapp/src/main/kotlin/org/connectbot/sshlib/example/SshClientExample.kt @@ -33,6 +33,7 @@ import org.connectbot.sshlib.SshClient import org.connectbot.sshlib.SshClientConfig import org.connectbot.sshlib.SshSession import org.slf4j.LoggerFactory +import java.io.Console fun main(args: Array) = SshCommand().main(args) @@ -108,7 +109,7 @@ private class SshCommand : CliktCommand(name = "ssh") { return retry.isNotEmpty() && client.authenticatePublicKey(user, keyData, retry) is AuthResult.Success } - private suspend fun resolvePassphrase(client: SshClient, keyData: String, console: java.io.Console?): String? { + private suspend fun resolvePassphrase(client: SshClient, keyData: String, console: Console?): String? { if (keyPassphrase != null) return keyPassphrase if (client.isPrivateKeyEncrypted(keyData)) return readPassphrase(console, "Enter passphrase for $keyFile: ") return null From ccab1991628073c6463ca6870a9d1aca53159447 Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Wed, 6 May 2026 20:35:52 -0700 Subject: [PATCH 4/4] fix: flaky kill -9 test for remote disconnects The disconnectedFlow might have fired before we were able to capture the event. --- .../sshlib/client/SshClientIntegrationTest.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt index 1c79a691..c03c9c0b 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield import org.connectbot.sshlib.AuthHandler import org.connectbot.sshlib.AuthPublicKey import org.connectbot.sshlib.AuthResult @@ -1082,7 +1083,7 @@ class SshClientIntegrationTest { } } - kotlinx.coroutines.yield() + yield() val session = client.openSession() assertNotNull(session) @@ -1156,13 +1157,19 @@ class SshClientIntegrationTest { assertNotNull(session) session!!.requestShell() + val disconnectDeferred = async { + withTimeout(10_000) { + client.disconnectedFlow.first() + } + } + + kotlinx.coroutines.yield() + // Force kill the server-side connection session.write("kill -9 \$PPID\n".toByteArray()) // Wait for disconnectedFlow to fire (so we know the packet loop ended) - withTimeout(10_000) { - client.disconnectedFlow.first() - } + disconnectDeferred.await() assertFalse(session.isOpen, "Session should be closed after server drops connection") assertNull(session.read(), "Read should return null on closed session")