From f544794b9715448210bd12e1bbaa1376f973e90b Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Sun, 3 May 2026 18:52:56 -0700 Subject: [PATCH 1/3] chore: add equalsverifier in other places --- .../connectbot/sshlib/client/SshConnection.kt | 18 ++-- .../org/connectbot/sshlib/SshClientTest.kt | 8 ++ .../sshlib/client/KeyBlobAlgorithmNameTest.kt | 94 +++++++++++++++++++ 3 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeyBlobAlgorithmNameTest.kt 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 dba12cd..ce96cd0 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt @@ -706,16 +706,6 @@ class SshConnection( return data.toByteArray() } - private fun keyBlobAlgorithmName(publicKeyBlob: ByteArray): String? { - if (publicKeyBlob.size < 4) return null - val len = ((publicKeyBlob[0].toInt() and 0xFF) shl 24) or - ((publicKeyBlob[1].toInt() and 0xFF) shl 16) or - ((publicKeyBlob[2].toInt() and 0xFF) shl 8) or - (publicKeyBlob[3].toInt() and 0xFF) - if (len <= 0 || len > publicKeyBlob.size - 4) return null - return String(publicKeyBlob, 4, len, Charsets.US_ASCII) - } - private fun negotiateRsaAlgorithm(): String = SignatureEntry.negotiateRsaAlgorithm(serverSigAlgs) /** @@ -2773,3 +2763,11 @@ internal fun selectPasswordMethods( else -> emptyList() } } + +internal fun keyBlobAlgorithmName(publicKeyBlob: ByteArray): String? { + if (publicKeyBlob.size < 4) return null + val stream = ByteBufferKaitaiStream(publicKeyBlob) + val len = stream.readU4be() + if (len <= 0 || len > publicKeyBlob.size - 4) return null + return String(stream.readBytes(len), Charsets.US_ASCII) +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/SshClientTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/SshClientTest.kt index ab54d40..f964289 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/SshClientTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/SshClientTest.kt @@ -17,6 +17,7 @@ package org.connectbot.sshlib import kotlinx.coroutines.test.runTest +import nl.jqno.equalsverifier.EqualsVerifier import org.connectbot.sshlib.transport.TransportFactory import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -59,6 +60,13 @@ class SshClientTest { override suspend fun onPasswordNeeded(): String? = null } + @Test + fun `AuthPublicKey equals and hashCode`() { + EqualsVerifier.forClass(AuthPublicKey::class.java) + .withPrefabValues(ByteArray::class.java, byteArrayOf(1, 2, 3), byteArrayOf(4, 5, 6)) + .verify() + } + @Test fun `connect returns TransportError when factory fails`() = runTest { val config = SshClientConfig { diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeyBlobAlgorithmNameTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeyBlobAlgorithmNameTest.kt new file mode 100644 index 0000000..0ca1cd0 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeyBlobAlgorithmNameTest.kt @@ -0,0 +1,94 @@ +/* + * 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.SshSigning +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class KeyBlobAlgorithmNameTest { + + private fun readKey(resourcePath: String): String = + javaClass.getResourceAsStream("/keys/$resourcePath")!!.bufferedReader().readText() + + /** Builds a minimal blob: 4-byte big-endian length prefix + ASCII name bytes. */ + private fun blobWithName(name: String): ByteArray { + val nameBytes = name.toByteArray(Charsets.US_ASCII) + val len = nameBytes.size + return byteArrayOf( + (len ushr 24).toByte(), + (len ushr 16).toByte(), + (len ushr 8).toByte(), + len.toByte(), + ) + nameBytes + } + + @Test + fun `returns null for empty blob`() { + assertNull(keyBlobAlgorithmName(byteArrayOf())) + } + + @Test + fun `returns null for blob shorter than 4 bytes`() { + assertNull(keyBlobAlgorithmName(byteArrayOf(0, 0, 0))) + } + + @Test + fun `returns null when length field is zero`() { + assertNull(keyBlobAlgorithmName(byteArrayOf(0, 0, 0, 0, 'x'.code.toByte()))) + } + + @Test + fun `returns null when length exceeds available bytes`() { + // length field says 5 but only 3 bytes follow + assertNull(keyBlobAlgorithmName(byteArrayOf(0, 0, 0, 5, 'a'.code.toByte(), 'b'.code.toByte(), 'c'.code.toByte()))) + } + + @Test + fun `returns null when length is one more than available bytes`() { + // length = 4, only 3 bytes of name data — kills ConditionalsBoundaryMutator on len > size-4 + val blob = byteArrayOf(0, 0, 0, 4, 'a'.code.toByte(), 'b'.code.toByte(), 'c'.code.toByte()) + assertNull(keyBlobAlgorithmName(blob)) + } + + @Test + fun `decodes when length exactly equals available bytes`() { + // length = 3, exactly 3 bytes follow — boundary passes, name is returned + val blob = byteArrayOf(0, 0, 0, 3, 'a'.code.toByte(), 'b'.code.toByte(), 'c'.code.toByte()) + assertEquals("abc", keyBlobAlgorithmName(blob)) + } + + @Test + fun `decodes ssh-ed25519 blob from real key`() { + val blob = SshSigning.getPublicKey("ssh-ed25519", readKey("ed25519_unencrypted"), null).publicKeyBlob + assertEquals("ssh-ed25519", keyBlobAlgorithmName(blob)) + } + + @Test + fun `decodes ssh-rsa blob from real key`() { + val blob = SshSigning.getPublicKey("ssh-rsa", readKey("rsa_unencrypted"), null).publicKeyBlob + assertEquals("ssh-rsa", keyBlobAlgorithmName(blob)) + } + + @Test + fun `decodes ecdsa-sha2-nistp256 blob from real key`() { + val blob = SshSigning.getPublicKey("ecdsa-sha2-nistp256", readKey("ecdsa256_unencrypted"), null).publicKeyBlob + assertEquals("ecdsa-sha2-nistp256", keyBlobAlgorithmName(blob)) + } + +} From 8ff0d512a1c27062d9145631254d0af47b6a9895 Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Wed, 6 May 2026 18:38:38 -0700 Subject: [PATCH 2/3] chore(AgentProtocol): make Agent more testable Extract some private functions to internal to allow unit testing. --- .../sshlib/client/AgentProtocolHandler.kt | 198 ++++++++-------- .../connectbot/sshlib/AgentProtocolTest.kt | 216 ++++++++++++++++-- .../sshlib/client/KeyBlobAlgorithmNameTest.kt | 4 +- 3 files changed, 294 insertions(+), 124 deletions(-) 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 fed9549..1dffe40 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentProtocolHandler.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentProtocolHandler.kt @@ -39,6 +39,96 @@ internal fun interface SessionBindVerifier { fun verify(hostKeyBlob: ByteArray, signature: ByteArray, data: ByteArray): Boolean } +internal data class BindingEntry(val hostKeyBlob: ByteArray, val sessionId: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as BindingEntry + if (!hostKeyBlob.contentEquals(other.hostKeyBlob)) return false + if (!sessionId.contentEquals(other.sessionId)) return false + return true + } + + override fun hashCode(): Int { + var result = hostKeyBlob.contentHashCode() + result = 31 * result + sessionId.contentHashCode() + return result + } +} + +internal data class SignedDataComponents( + val methodName: String, + val destUsername: String, + val serverHostKeyBlob: ByteArray?, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as SignedDataComponents + if (methodName != other.methodName) return false + if (destUsername != other.destUsername) return false + if (!(serverHostKeyBlob == null && other.serverHostKeyBlob == null) && + ( + serverHostKeyBlob == null || other.serverHostKeyBlob == null || + !serverHostKeyBlob.contentEquals(other.serverHostKeyBlob) + ) + ) { + return false + } + return true + } + + override fun hashCode(): Int { + var result = methodName.hashCode() + result = 31 * result + destUsername.hashCode() + result = 31 * result + serverHostKeyBlob.contentHashCode() + return result + } +} + +internal fun buildAgentMessage(messageType: Int, payload: ByteArray): ByteArray { + val totalLength = 1L + payload.size + val stream = ByteBufferKaitaiStream(4 + totalLength) + stream.writeU4be(totalLength) + stream.writeU1(messageType) + stream.writeBytes(payload) + stream.seek(0) + return stream.readBytesFull() +} + +internal fun isConstraintSatisfied( + constraints: List, + components: SignedDataComponents, + bindingList: List, +): Boolean { + val isForwarding = bindingList.isNotEmpty() + val forwardingHopKey = if (bindingList.size >= 2) { + bindingList[bindingList.size - 2].hostKeyBlob + } else if (bindingList.size == 1) { + bindingList[0].hostKeyBlob + } else { + null + } + + return constraints.any { c -> + val fromMatches = if (!isForwarding) { + c.fromHostname.isEmpty() && c.fromKeyspecs.isEmpty() + } else { + forwardingHopKey != null && c.fromKeyspecs.any { spec -> + spec.keyBlob.contentEquals(forwardingHopKey) + } + } + if (!fromMatches) return@any false + + val usernameMatches = c.toUsername.isEmpty() || c.toUsername == components.destUsername + if (!usernameMatches) return@any false + + val hostKeyMatches = components.serverHostKeyBlob != null && + c.toHostspecs.any { spec -> spec.keyBlob.contentEquals(components.serverHostKeyBlob) } + hostKeyMatches + } +} + internal class AgentProtocolHandler( private val provider: AgentProvider, private val sessionInfo: AgentSessionInfo, @@ -57,27 +147,9 @@ internal class AgentProtocolHandler( const val SSH_AGENTC_EXTENSION: Int = 27 const val SSH_AGENT_SUCCESS: Int = 6 - private const val METHOD_PUBLICKEY = "publickey" private const val METHOD_PUBLICKEY_HOSTBOUND = "publickey-hostbound-v00@openssh.com" } - private data class BindingEntry(val hostKeyBlob: ByteArray, val sessionId: ByteArray) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as BindingEntry - if (!hostKeyBlob.contentEquals(other.hostKeyBlob)) return false - if (!sessionId.contentEquals(other.sessionId)) return false - return true - } - - override fun hashCode(): Int { - var result = hostKeyBlob.contentHashCode() - result = 31 * result + sessionId.contentHashCode() - return result - } - } - private val bindingList: MutableList = mutableListOf() suspend fun handleRequest(requestBytes: ByteArray): ByteArray { @@ -166,13 +238,11 @@ internal class AgentProtocolHandler( return createFailureResponse() } - // For direct connections with standard publickey auth (no embedded server host key), - // use the session's server host key as the implicit destination. if (bindingList.isEmpty() && components.serverHostKeyBlob == null) { components = components.copy(serverHostKeyBlob = sessionInfo.serverHostKey) } - if (!isConstraintSatisfied(constraints, components)) { + if (!isConstraintSatisfied(constraints, components, bindingList)) { logger.warn("Destination constraint not satisfied for key") return createFailureResponse() } @@ -206,37 +276,6 @@ internal class AgentProtocolHandler( } } - private data class SignedDataComponents( - val methodName: String, - val destUsername: String, - val serverHostKeyBlob: ByteArray?, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as SignedDataComponents - if (methodName != other.methodName) return false - if (destUsername != other.destUsername) return false - if (!serverHostKeyBlob.contentEquals(other.serverHostKeyBlob)) return false - return true - } - - override fun hashCode(): Int { - var result = methodName.hashCode() - result = 31 * result + destUsername.hashCode() - result = 31 * result + serverHostKeyBlob.contentHashCode() - return result - } - } - - private fun ByteArray?.contentEquals(other: ByteArray?): Boolean { - if (this == null && other == null) return true - if (this == null || other == null) return false - return java.util.Arrays.equals(this, other) - } - - private fun ByteArray?.contentHashCode(): Int = this?.contentHashCode() ?: 0 - private fun parseSignedDataComponents(data: ByteArray): SignedDataComponents? = try { val stream = ByteBufferKaitaiStream(data) val sigData = UserauthPublickeySignatureDataAny(stream) @@ -252,40 +291,6 @@ internal class AgentProtocolHandler( null } - private fun isConstraintSatisfied( - constraints: List, - components: SignedDataComponents, - ): Boolean { - val isForwarding = bindingList.isNotEmpty() - // The forwarding hop key is the second-to-last binding (the host that relayed to us). - // The last binding is the destination's connection key, also represented in components.serverHostKeyBlob. - val forwardingHopKey = if (bindingList.size >= 2) { - bindingList[bindingList.size - 2].hostKeyBlob - } else if (bindingList.size == 1) { - bindingList[0].hostKeyBlob - } else { - null - } - - return constraints.any { c -> - val fromMatches = if (!isForwarding) { - c.fromHostname.isEmpty() && c.fromKeyspecs.isEmpty() - } else { - forwardingHopKey != null && c.fromKeyspecs.any { spec -> - spec.keyBlob.contentEquals(forwardingHopKey) - } - } - if (!fromMatches) return@any false - - val usernameMatches = c.toUsername.isEmpty() || c.toUsername == components.destUsername - if (!usernameMatches) return@any false - - val hostKeyMatches = components.serverHostKeyBlob != null && - c.toHostspecs.any { spec -> spec.keyBlob.contentEquals(components.serverHostKeyBlob) } - hostKeyMatches - } - } - private suspend fun handleExtension(message: SshAgentMessage): ByteArray { logger.debug("Handling EXTENSION") @@ -311,21 +316,18 @@ internal class AgentProtocolHandler( val hostKeyBlob = bind.hostkey().data() val sessionId = bind.sessionIdentifier().data() - val isForwarding = bind.isForwarding().toInt() != 0 + val isForwarding = bind.isForwarding() != 0 - // Replay protection: reject duplicate session IDs if (bindingList.any { it.sessionId.contentEquals(sessionId) }) { logger.warn("Session bind replay: session ID already recorded") return createFailureResponse() } - // For non-forwarding (origin) binds, verify the hostkey matches the connection's server key if (!isForwarding && !hostKeyBlob.contentEquals(sessionInfo.serverHostKey)) { logger.error("Session bind hostkey mismatch for non-forwarding bind") return createFailureResponse() } - // Cryptographically verify the session bind signature if (!bindVerifier.verify(hostKeyBlob, bind.signature().data(), sessionId)) { logger.error("Session bind signature verification failed") return createFailureResponse() @@ -343,22 +345,6 @@ internal class AgentProtocolHandler( return payload } - private fun buildAgentMessage(messageType: Int, payload: ByteArray): ByteArray { - val totalLength = 1 + payload.size - val buffer = ByteArray(4 + totalLength) - - buffer[0] = ((totalLength shr 24) and 0xFF).toByte() - buffer[1] = ((totalLength shr 16) and 0xFF).toByte() - buffer[2] = ((totalLength shr 8) and 0xFF).toByte() - buffer[3] = (totalLength and 0xFF).toByte() - - buffer[4] = messageType.toByte() - - System.arraycopy(payload, 0, buffer, 5, payload.size) - - return buffer - } - private fun createFailureResponse(): ByteArray = buildAgentMessage(SSH_AGENT_FAILURE, ByteArray(0)) private fun createSuccessResponse(): ByteArray = buildAgentMessage(SSH_AGENT_SUCCESS, ByteArray(0)) diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/AgentProtocolTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/AgentProtocolTest.kt index b7f23cb..a3b1f8d 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/AgentProtocolTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/AgentProtocolTest.kt @@ -21,7 +21,11 @@ import kotlinx.coroutines.test.runTest import nl.jqno.equalsverifier.EqualsVerifier import org.connectbot.sshlib.client.AgentProtocolHandler import org.connectbot.sshlib.client.AgentSessionInfo +import org.connectbot.sshlib.client.BindingEntry import org.connectbot.sshlib.client.SessionBindVerifier +import org.connectbot.sshlib.client.SignedDataComponents +import org.connectbot.sshlib.client.buildAgentMessage +import org.connectbot.sshlib.client.isConstraintSatisfied import org.connectbot.sshlib.protocol.SshAgentIdentitiesAnswer import org.connectbot.sshlib.protocol.SshAgentMessage import org.connectbot.sshlib.protocol.SshAgentSignResponse @@ -405,24 +409,206 @@ class AgentProtocolTest { } @Test - fun `buildAgentMessage encodes length field correctly for large payload`() = runTest { - // Use a payload of 256 bytes so the length field requires byte[1] (not just byte[3]) to be non-zero. - // If shr 16 is mutated to shr 8 (or removed), byte[1] would encode incorrectly and parsing would fail. - val largePayload = ByteArray(256) { it.toByte() } - val message = buildAgentMessage(12, largePayload) - val (msgType, parsedPayload) = parseAgentMessage(message) - assertEquals(12, msgType) - assertArrayEquals(largePayload, parsedPayload) + fun `buildAgentMessage encodes length field correctly for 255-byte signature`() = runTest { + // A 255-byte signature makes the SIGN_RESPONSE payload large enough that byte[2] of the + // length field is non-zero. Routes through the production buildAgentMessage (not the local helper). + val largeSignature = ByteArray(255) { it.toByte() } + val testProvider = object : AgentProvider { + override suspend fun getIdentities(): List = emptyList() + override suspend fun signData(context: AgentSigningContext): ByteArray = largeSignature + } + val handler = AgentProtocolHandler(testProvider, AgentSessionInfo(byteArrayOf(1), byteArrayOf(2))) + val signRequest = SshAgentcSignRequest() + signRequest.setKeyBlob(createByteString(byteArrayOf(1))) + signRequest.setData(createByteString(byteArrayOf(2))) + signRequest.setFlags(0) + signRequest._check() + val response = handler.handleRequest(buildAgentMessage(13, signRequest.toByteArray())) + val (messageType, payload) = parseAgentMessage(response) + assertEquals(14, messageType) + val sig = SshAgentSignResponse(ByteBufferKaitaiStream(payload)) + sig._read() + assertArrayEquals(largeSignature, sig.signature().data()) + } + + @Test + fun `buildAgentMessage encodes length field correctly for 65535-byte signature`() = runTest { + // A 65535-byte signature forces byte[1] of the length field to be non-zero. + // Routes through the production buildAgentMessage. + val hugeSignature = ByteArray(65535) { it.toByte() } + val testProvider = object : AgentProvider { + override suspend fun getIdentities(): List = emptyList() + override suspend fun signData(context: AgentSigningContext): ByteArray = hugeSignature + } + val handler = AgentProtocolHandler(testProvider, AgentSessionInfo(byteArrayOf(1), byteArrayOf(2))) + val signRequest = SshAgentcSignRequest() + signRequest.setKeyBlob(createByteString(byteArrayOf(1))) + signRequest.setData(createByteString(byteArrayOf(2))) + signRequest.setFlags(0) + signRequest._check() + val response = handler.handleRequest(buildAgentMessage(13, signRequest.toByteArray())) + val (messageType, payload) = parseAgentMessage(response) + assertEquals(14, messageType) + val sig = SshAgentSignResponse(ByteBufferKaitaiStream(payload)) + sig._read() + assertArrayEquals(hugeSignature, sig.signature().data()) + } +} + +class BuildAgentMessageTest { + + private fun parseMessage(bytes: ByteArray): Pair { + val buf = ByteBuffer.wrap(bytes) + val length = buf.int + val msgType = buf.get().toInt() and 0xFF + val payload = ByteArray(length - 1) + buf.get(payload) + return msgType to payload + } + + @Test + fun `empty payload encodes correctly`() { + val result = buildAgentMessage(5, ByteArray(0)) + val (msgType, payload) = parseMessage(result) + assertEquals(5, msgType) + assertArrayEquals(ByteArray(0), payload) } @Test - fun `buildAgentMessage encodes length field correctly for 65536-byte payload`() = runTest { - // Payload of 65536 bytes forces byte[0] of the length field to be non-zero. - // Kills MathMutator survivors on the shr 24 / shr 16 shift expressions. - val hugePayload = ByteArray(65536) { it.toByte() } - val message = buildAgentMessage(14, hugePayload) - val (msgType, parsedPayload) = parseAgentMessage(message) + fun `small payload roundtrips`() { + val data = byteArrayOf(1, 2, 3) + val result = buildAgentMessage(14, data) + val (msgType, payload) = parseMessage(result) assertEquals(14, msgType) - assertArrayEquals(hugePayload, parsedPayload) + assertArrayEquals(data, payload) + } + + @Test + fun `255-byte payload makes byte 2 of length non-zero`() { + val data = ByteArray(255) { it.toByte() } + val result = buildAgentMessage(12, data) + val (msgType, payload) = parseMessage(result) + assertEquals(12, msgType) + assertArrayEquals(data, payload) + assertTrue(result[2] != 0.toByte()) { "byte[2] should be non-zero for payload size 255" } + } + + @Test + fun `65535-byte payload makes byte 1 of length non-zero`() { + val data = ByteArray(65535) { it.toByte() } + val result = buildAgentMessage(6, data) + val (msgType, payload) = parseMessage(result) + assertEquals(6, msgType) + assertArrayEquals(data, payload) + assertTrue(result[1] != 0.toByte()) { "byte[1] should be non-zero for payload size 65535" } + } +} + +class IsConstraintSatisfiedTest { + + private fun constraint( + fromHostname: String = "", + fromKeyspecs: List = emptyList(), + toUsername: String = "", + toHostname: String = "", + toHostspecs: List = emptyList(), + ) = DestinationConstraint(fromHostname, fromKeyspecs, toUsername, toHostname, toHostspecs) + + private fun keyspec(blob: ByteArray) = AgentKeySpec(blob, false) + + private val hostKey = byteArrayOf(0x01, 0x02, 0x03) + private val sessionId = byteArrayOf(0x04, 0x05, 0x06) + + @Test + fun `direct connection - no constraints - always rejected`() { + val components = SignedDataComponents("publickey", "user", hostKey) + assertFalse(isConstraintSatisfied(emptyList(), components, emptyList())) + } + + @Test + fun `direct connection - empty from constraint with matching toHostspec - satisfied`() { + val c = constraint(toHostspecs = listOf(keyspec(hostKey))) + val components = SignedDataComponents("publickey", "user", hostKey) + assertTrue(isConstraintSatisfied(listOf(c), components, emptyList())) + } + + @Test + fun `direct connection - empty from constraint with non-matching toHostspec - rejected`() { + val c = constraint(toHostspecs = listOf(keyspec(byteArrayOf(0x99.toByte())))) + val components = SignedDataComponents("publickey", "user", hostKey) + assertFalse(isConstraintSatisfied(listOf(c), components, emptyList())) + } + + @Test + fun `direct connection - toUsername mismatch - rejected`() { + val c = constraint(toHostspecs = listOf(keyspec(hostKey)), toUsername = "alice") + val components = SignedDataComponents("publickey", "bob", hostKey) + assertFalse(isConstraintSatisfied(listOf(c), components, emptyList())) + } + + @Test + fun `direct connection - toUsername matches - satisfied`() { + val c = constraint(toHostspecs = listOf(keyspec(hostKey)), toUsername = "alice") + val components = SignedDataComponents("publickey", "alice", hostKey) + assertTrue(isConstraintSatisfied(listOf(c), components, emptyList())) + } + + @Test + fun `direct connection - null serverHostKeyBlob - rejected`() { + val c = constraint(toHostspecs = listOf(keyspec(hostKey))) + val components = SignedDataComponents("publickey", "user", null) + assertFalse(isConstraintSatisfied(listOf(c), components, emptyList())) + } + + @Test + fun `forwarded connection - single binding - fromKeyspec matches hop - satisfied`() { + val hopKey = byteArrayOf(0xAA.toByte()) + val destKey = byteArrayOf(0xBB.toByte()) + val binding = BindingEntry(hopKey, sessionId) + val c = constraint(fromKeyspecs = listOf(keyspec(hopKey)), toHostspecs = listOf(keyspec(destKey))) + val components = SignedDataComponents("publickey-hostbound-v00@openssh.com", "user", destKey) + assertTrue(isConstraintSatisfied(listOf(c), components, listOf(binding))) + } + + @Test + fun `forwarded connection - single binding - fromKeyspec does not match - rejected`() { + val hopKey = byteArrayOf(0xAA.toByte()) + val wrongKey = byteArrayOf(0xCC.toByte()) + val destKey = byteArrayOf(0xBB.toByte()) + val binding = BindingEntry(hopKey, sessionId) + val c = constraint(fromKeyspecs = listOf(keyspec(wrongKey)), toHostspecs = listOf(keyspec(destKey))) + val components = SignedDataComponents("publickey-hostbound-v00@openssh.com", "user", destKey) + assertFalse(isConstraintSatisfied(listOf(c), components, listOf(binding))) + } + + @Test + fun `forwarded connection - two bindings - uses second-to-last as hop key`() { + val hop1Key = byteArrayOf(0xAA.toByte()) + val hop2Key = byteArrayOf(0xBB.toByte()) + val destKey = byteArrayOf(0xCC.toByte()) + val bindings = listOf(BindingEntry(hop1Key, byteArrayOf(1)), BindingEntry(hop2Key, byteArrayOf(2))) + val c = constraint(fromKeyspecs = listOf(keyspec(hop1Key)), toHostspecs = listOf(keyspec(destKey))) + val components = SignedDataComponents("publickey-hostbound-v00@openssh.com", "user", destKey) + assertTrue(isConstraintSatisfied(listOf(c), components, bindings)) + } +} + +class BindingEntryEqualsTest { + + @Test + fun `equals and hashCode`() { + EqualsVerifier.forClass(BindingEntry::class.java) + .withPrefabValues(ByteArray::class.java, byteArrayOf(1, 2, 3), byteArrayOf(4, 5, 6)) + .verify() + } +} + +class SignedDataComponentsEqualsTest { + + @Test + fun `equals and hashCode`() { + EqualsVerifier.forClass(SignedDataComponents::class.java) + .withPrefabValues(ByteArray::class.java, byteArrayOf(1, 2, 3), byteArrayOf(4, 5, 6)) + .verify() } } diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeyBlobAlgorithmNameTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeyBlobAlgorithmNameTest.kt index 0ca1cd0..9dc6ccc 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeyBlobAlgorithmNameTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeyBlobAlgorithmNameTest.kt @@ -23,8 +23,7 @@ import org.junit.jupiter.api.Test class KeyBlobAlgorithmNameTest { - private fun readKey(resourcePath: String): String = - javaClass.getResourceAsStream("/keys/$resourcePath")!!.bufferedReader().readText() + private fun readKey(resourcePath: String): String = javaClass.getResourceAsStream("/keys/$resourcePath")!!.bufferedReader().readText() /** Builds a minimal blob: 4-byte big-endian length prefix + ASCII name bytes. */ private fun blobWithName(name: String): ByteArray { @@ -90,5 +89,4 @@ class KeyBlobAlgorithmNameTest { val blob = SshSigning.getPublicKey("ecdsa-sha2-nistp256", readKey("ecdsa256_unencrypted"), null).publicKeyBlob assertEquals("ecdsa-sha2-nistp256", keyBlobAlgorithmName(blob)) } - } From 6ab3cdf2da6e6c1bdd3f387d767d676a6bf87a1f Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Thu, 7 May 2026 10:05:21 +0800 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../kotlin/org/connectbot/sshlib/client/AgentProtocolHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1dffe40..b4f15c3 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentProtocolHandler.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentProtocolHandler.kt @@ -81,7 +81,7 @@ internal data class SignedDataComponents( override fun hashCode(): Int { var result = methodName.hashCode() result = 31 * result + destUsername.hashCode() - result = 31 * result + serverHostKeyBlob.contentHashCode() + result = 31 * result + (serverHostKeyBlob?.contentHashCode() ?: 0) return result } }