diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentChannel.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentChannel.kt index c8d3519..3b07b13 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentChannel.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentChannel.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,6 +27,7 @@ internal class AgentChannel( private var remoteChannelNumber: Int, private val maxPacketSize: Int, remoteWindowSizeInitial: Long, + initialWindowSize: Int = 64 * 1024, ) { companion object { private val logger = LoggerFactory.getLogger(AgentChannel::class.java) @@ -35,7 +36,7 @@ internal class AgentChannel( private var _isOpen = true private var closeSent = false - @Volatile private var remoteWindowSize: Long = remoteWindowSizeInitial + private val window = LocalChannelWindow(initialWindowSize, remoteInitial = remoteWindowSizeInitial) private val windowAvailable = Channel(Channel.CONFLATED) val isOpen: Boolean get() = _isOpen @@ -45,8 +46,12 @@ internal class AgentChannel( logger.warn("Received data on closed agent channel") return } + val adjust = window.consumeLocal(data.size) logger.debug("Agent channel received ${data.size} bytes") + if (adjust > 0) { + connection.sendWindowAdjust(remoteChannelNumber, adjust) + } val response = handler.handleRequest(data) @@ -55,9 +60,9 @@ internal class AgentChannel( } fun onWindowAdjust(bytesToAdd: Long) { - remoteWindowSize += bytesToAdd - logger.debug("Agent channel window adjust +$bytesToAdd, remote window now $remoteWindowSize") - if (remoteWindowSize > 0) { + window.adjustRemote(bytesToAdd) + logger.debug("Agent channel window adjust +$bytesToAdd, remote window now ${window.remoteRemaining}") + if (window.remoteRemaining > 0) { windowAvailable.trySend(Unit) } } @@ -83,17 +88,13 @@ internal class AgentChannel( private suspend fun sendData(data: ByteArray) { var offset = 0 while (offset < data.size) { - while (remoteWindowSize <= 0) { + while (window.remoteRemaining <= 0) { windowAvailable.receive() } - val chunkSize = minOf( - data.size - offset, - remoteWindowSize.toInt(), - maxPacketSize, - ) + val chunkSize = window.sendChunkSize(data.size - offset, maxPacketSize) val chunk = data.copyOfRange(offset, offset + chunkSize) connection.sendChannelData(remoteChannelNumber, chunk) - remoteWindowSize -= chunkSize + window.consumeRemote(chunkSize) offset += chunkSize } } 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 ee676d2..3179e2a 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentProtocolHandler.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentProtocolHandler.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -135,7 +135,7 @@ internal class AgentProtocolHandler( private val provider: AgentProvider, private val sessionInfo: AgentSessionInfo, private val bindVerifier: SessionBindVerifier = SessionBindVerifier { hk, sig, data -> - SignatureVerifier.verify(hk, sig, data) + SignatureVerifier.verifyWithKeyType(hk, sig, data) }, ) { companion object { diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/ForwardingChannel.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/ForwardingChannel.kt index 2e80434..4899de3 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/ForwardingChannel.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/ForwardingChannel.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,9 +36,11 @@ internal class ForwardingChannel( private var _isOpen = true private var closeSent = false - private var localWindowSize: Long = initialWindowSize.toLong() - - @Volatile private var remoteWindowSize: Long = remoteWindowSizeInitial + private val window = LocalChannelWindow( + initialWindowSize, + adjustThreshold = WINDOW_ADJUST_THRESHOLD, + remoteInitial = remoteWindowSizeInitial, + ) private val windowAvailable = Channel(Channel.CONFLATED) private val _incomingData = Channel(Channel.UNLIMITED) @@ -47,19 +49,17 @@ internal class ForwardingChannel( val isOpen: Boolean get() = _isOpen internal suspend fun onData(data: ByteArray) { + val adjust = window.consumeLocal(data.size) _incomingData.trySend(data) - localWindowSize -= data.size - if (localWindowSize < WINDOW_ADJUST_THRESHOLD) { - val adjust = initialWindowSize - localWindowSize.toInt() - localWindowSize += adjust + if (adjust > 0) { connection.sendWindowAdjust(remoteChannelNumber, adjust) } } internal fun onWindowAdjust(bytesToAdd: Long) { - remoteWindowSize += bytesToAdd - logger.debug("Forwarding channel window adjust +$bytesToAdd, remote window now $remoteWindowSize") - if (remoteWindowSize > 0) { + window.adjustRemote(bytesToAdd) + logger.debug("Forwarding channel window adjust +$bytesToAdd, remote window now ${window.remoteRemaining}") + if (window.remoteRemaining > 0) { windowAvailable.trySend(Unit) } } @@ -87,17 +87,13 @@ internal class ForwardingChannel( suspend fun sendData(data: ByteArray) { var offset = 0 while (offset < data.size) { - while (remoteWindowSize <= 0) { + while (window.remoteRemaining <= 0) { windowAvailable.receive() } - val chunkSize = minOf( - data.size - offset, - remoteWindowSize.toInt(), - maxPacketSize, - ) + val chunkSize = window.sendChunkSize(data.size - offset, maxPacketSize) val chunk = data.copyOfRange(offset, offset + chunkSize) connection.sendChannelData(remoteChannelNumber, chunk) - remoteWindowSize -= chunkSize + window.consumeRemote(chunkSize) offset += chunkSize } } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/LocalChannelWindow.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/LocalChannelWindow.kt new file mode 100644 index 0000000..2b680ad --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/LocalChannelWindow.kt @@ -0,0 +1,94 @@ +/* + * ConnectBot SSH Library + * Copyright 2025-2026 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.SshException + +/** + * Tracks SSH channel flow control windows per RFC 4254 §5.2. + * + * Local window: bytes we are willing to receive. Enforces that the server never + * exceeds it; auto-refills when below [adjustThreshold]. + * + * Remote window: bytes the server is willing to receive. Enforces uint32 max + * and positive-only adjustments per the protocol. + */ +internal class LocalChannelWindow( + private val initialSize: Int, + private val adjustThreshold: Int = 16 * 1024, + remoteInitial: Long = 0, +) { + companion object { + const val MAX_WINDOW_SIZE = 0xFFFFFFFFL + } + + private var localRemaining: Long = initialSize.toLong() + + @Volatile var remoteRemaining: Long = remoteInitial + private set + + /** + * Consume [size] bytes from the local window, rejecting excess data. + * Returns the window-adjust amount to send back (0 if no adjust needed). + */ + fun consumeLocal(size: Int): Int { + if (size > localRemaining) { + throw SshException("Server sent $size bytes exceeding local window ($localRemaining)") + } + localRemaining -= size + return if (localRemaining < adjustThreshold) { + val adjust = initialSize - localRemaining.toInt() + localRemaining += adjust + adjust + } else { + 0 + } + } + + /** + * Apply a window adjustment from the server, increasing the remote window. + * Validates [bytesToAdd] is positive and won't overflow the uint32 max. + */ + fun adjustRemote(bytesToAdd: Long) { + if (bytesToAdd <= 0) { + throw SshException("Invalid window adjust: bytesToAdd must be positive, got $bytesToAdd") + } + if (remoteRemaining + bytesToAdd > MAX_WINDOW_SIZE) { + throw SshException("Channel window overflow: current=$remoteRemaining, adding=$bytesToAdd exceeds max $MAX_WINDOW_SIZE") + } + remoteRemaining += bytesToAdd + } + + /** + * Consume [size] bytes from the remote window for sending. + * Caller must ensure [remoteRemaining] > 0 before calling. + */ + fun consumeRemote(size: Int) { + remoteRemaining -= size + } + + /** + * Return the next send chunk size without narrowing the uint32 remote + * window until it is bounded by Int-sized data and packet limits. + */ + fun sendChunkSize(remainingData: Int, maxPacketSize: Int): Int = minOf( + remainingData.toLong(), + remoteRemaining, + maxPacketSize.toLong(), + ).toInt() +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SessionChannel.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SessionChannel.kt index d4ef7e7..65e377f 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SessionChannel.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SessionChannel.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,14 +51,11 @@ class SessionChannel internal constructor( ) : SshSession { companion object { private val logger = LoggerFactory.getLogger(SessionChannel::class.java) - private const val WINDOW_ADJUST_THRESHOLD = 16 * 1024 } private var _isOpen = true private var closeSent = false - private var localWindowSize: Long = initialWindowSize.toLong() - - @Volatile private var remoteWindowSize: Long = remoteWindowSizeInitial + private val window = LocalChannelWindow(initialWindowSize, remoteInitial = remoteWindowSizeInitial) private val windowAvailable = Channel(Channel.CONFLATED) private val _stdout = Channel(Channel.UNLIMITED) @@ -86,32 +83,28 @@ class SessionChannel internal constructor( get() = ptyGranted && canSendChaff && obscureKeystrokeTimingIntervalMs > 0 internal suspend fun onData(data: ByteArray) { + val adjust = window.consumeLocal(data.size) _stdout.trySend(data) - localWindowSize -= data.size - if (localWindowSize < WINDOW_ADJUST_THRESHOLD) { - val adjust = initialWindowSize - localWindowSize.toInt() - localWindowSize += adjust + if (adjust > 0) { connection.sendWindowAdjust(_remoteChannelNumber, adjust) } } internal suspend fun onExtendedData(dataType: Int, data: ByteArray) { + val adjust = window.consumeLocal(data.size) _extendedData.trySend(dataType to data) if (dataType == 1) { _stderr.trySend(data) } - localWindowSize -= data.size - if (localWindowSize < WINDOW_ADJUST_THRESHOLD) { - val adjust = initialWindowSize - localWindowSize.toInt() - localWindowSize += adjust + if (adjust > 0) { connection.sendWindowAdjust(_remoteChannelNumber, adjust) } } internal fun onWindowAdjust(bytesToAdd: Long) { - remoteWindowSize += bytesToAdd - logger.debug("Window adjust +$bytesToAdd, remote window now $remoteWindowSize") - if (remoteWindowSize > 0) { + window.adjustRemote(bytesToAdd) + logger.debug("Window adjust +$bytesToAdd, remote window now ${window.remoteRemaining}") + if (window.remoteRemaining > 0) { windowAvailable.trySend(Unit) } } @@ -155,17 +148,13 @@ class SessionChannel internal constructor( private suspend fun writeDirect(data: ByteArray) { var offset = 0 while (offset < data.size) { - while (remoteWindowSize <= 0) { + while (window.remoteRemaining <= 0) { windowAvailable.receive() } - val chunkSize = minOf( - data.size - offset, - remoteWindowSize.toInt(), - maxPacketSize, - ) + val chunkSize = window.sendChunkSize(data.size - offset, maxPacketSize) val chunk = data.copyOfRange(offset, offset + chunkSize) connection.sendChannelData(_remoteChannelNumber, chunk) - remoteWindowSize -= chunkSize + window.consumeRemote(chunkSize) offset += chunkSize } } 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 43dcdeb..1c73aff 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt @@ -1329,7 +1329,9 @@ class SshConnection( } serverHostKeyBlob = serverHostKey - if (!SignatureVerifier.verify(serverHostKey, signature, hash)) { + val negotiatedAlg = negotiatedHostKeyAlgorithm + ?: throw SshException("No host key algorithm negotiated") + if (!SignatureVerifier.verify(serverHostKey, signature, hash, negotiatedAlg)) { logger.error("Server signature verification failed") throw SshException("Server signature verification failed") } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/DiffieHellmanGroupExchange.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/DiffieHellmanGroupExchange.kt index 10053e2..b48d2fb 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/DiffieHellmanGroupExchange.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/DiffieHellmanGroupExchange.kt @@ -44,6 +44,21 @@ internal class DiffieHellmanGroupExchange(override val hashAlgorithm: String) : private var privateKey: BigInteger? = null fun setGroup(p: BigInteger, g: BigInteger) { + val pBits = p.bitLength() + if (pBits < min) { + throw SshException("DH group prime too small: $pBits bits < $min bits minimum") + } + if (pBits > max) { + throw SshException("DH group prime too large: $pBits bits > $max bits maximum") + } + // p must be odd (a basic primality sanity check: all primes > 2 are odd) + if (!p.testBit(0)) { + throw SshException("DH group prime is even") + } + // g must satisfy 1 < g < p-1 to be a valid generator candidate + if (g <= BigInteger.ONE || g >= p - BigInteger.ONE) { + throw SshException("DH group generator g is out of range: must be 1 < g < p-1") + } this.p = p this.g = g } 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 4f6f1ad..bdb9a12 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -96,7 +96,7 @@ internal class EcdhKeyExchange(private val curveName: String) : KexAlgorithm { agreement.doPhase(serverPubKey, true) val rawSecret = agreement.generateSecret() - return encodeMpint(BigInteger(1, rawSecret).toByteArray()) + return computeSharedSecretFromRaw(rawSecret) } catch (e: SshException) { throw e } catch (e: Exception) { @@ -104,6 +104,13 @@ internal class EcdhKeyExchange(private val curveName: String) : KexAlgorithm { } } + internal fun computeSharedSecretFromRaw(rawSecret: ByteArray): ByteArray { + if (rawSecret.all { it == 0.toByte() }) { + throw SshException("Invalid ECDH shared secret: all zeroes") + } + return encodeMpint(BigInteger(1, rawSecret).toByteArray()) + } + private fun encodeEcPoint(point: ECPoint): ByteArray { val x = point.affineX.toByteArray() val y = point.affineY.toByteArray() diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SignatureVerifier.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SignatureVerifier.kt index 6d3d0c1..aaf7308 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SignatureVerifier.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SignatureVerifier.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,10 +23,22 @@ import org.connectbot.sshlib.protocol.SshSignature internal object SignatureVerifier { - fun verify(serverHostKey: ByteArray, signatureData: ByteArray, exchangeHash: ByteArray): Boolean { + /** + * Verifies the server's host key signature during KEX. + * + * Enforces that the algorithm name in the signature blob matches [expectedAlgorithm] + * (the negotiated host key algorithm), per RFC 8332 §3.2. + */ + fun verify(serverHostKey: ByteArray, signatureData: ByteArray, exchangeHash: ByteArray, expectedAlgorithm: String): Boolean { val sig = SshSignature(ByteBufferKaitaiStream(signatureData)) sig._read() + // RFC 8332 §3.2: reject if the algorithm in the signature blob doesn't match + // what was negotiated. Prevents a server from downgrading e.g. rsa-sha2-256 → ssh-rsa. + if (sig.algorithmName() != expectedAlgorithm) { + return false + } + val pubKey = SshPublicKey(ByteBufferKaitaiStream(serverHostKey)) pubKey._read() @@ -35,4 +47,33 @@ internal object SignatureVerifier { return algorithm.verify(pubKey, sig, exchangeHash) } + + /** + * Verifies a signature where the algorithm is self-described in the signature blob + * (e.g., agent session binding). The algorithm must be compatible with the key type + * of [hostKey]. + */ + fun verifyWithKeyType(hostKey: ByteArray, signatureData: ByteArray, data: ByteArray): Boolean { + val sig = SshSignature(ByteBufferKaitaiStream(signatureData)) + sig._read() + + val pubKey = SshPublicKey(ByteBufferKaitaiStream(hostKey)) + pubKey._read() + + val sigAlgorithm = sig.algorithmName() + val keyType = pubKey.algorithmName() + + // Ensure the sig algorithm is compatible with the key type to prevent cross-type forgery. + if (!isAlgorithmCompatibleWithKeyType(sigAlgorithm, keyType)) { + return false + } + + val algorithm = SignatureEntry.fromSshName(sigAlgorithm)?.algorithm ?: return false + return algorithm.verify(pubKey, sig, data) + } + + private fun isAlgorithmCompatibleWithKeyType(sigAlgorithm: String, keyType: String): Boolean = when (keyType) { + "ssh-rsa" -> sigAlgorithm in setOf("ssh-rsa", "rsa-sha2-256", "rsa-sha2-512") + else -> sigAlgorithm == keyType + } } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/ZlibCompressor.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/ZlibCompressor.kt index f3d235e..13c393c 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/ZlibCompressor.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/ZlibCompressor.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,10 +17,16 @@ package org.connectbot.sshlib.crypto +import org.connectbot.sshlib.SshException import java.util.zip.Deflater import java.util.zip.Inflater internal class ZlibCompressor : PacketCompressor { + companion object { + // Max decompressed size per packet. Prevents decompression-bomb DoS from a malicious server. + internal const val MAX_UNCOMPRESSED_SIZE = 512 * 1024 + } + private val deflater = Deflater(5) private val inflater = Inflater() @@ -54,8 +60,12 @@ internal class ZlibCompressor : PacketCompressor { val count = inflater.inflate(output, totalOut, output.size - totalOut) totalOut += count if (count == 0) break + if (totalOut > MAX_UNCOMPRESSED_SIZE) { + throw SshException("Decompressed packet exceeds maximum allowed size ($MAX_UNCOMPRESSED_SIZE bytes)") + } if (totalOut == output.size) { - output = output.copyOf(output.size * 2) + val newSize = minOf(output.size * 2, MAX_UNCOMPRESSED_SIZE + 1) + output = output.copyOf(newSize) } } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/transport/PacketIO.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/transport/PacketIO.kt index e4af4d6..219bcde 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/transport/PacketIO.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/transport/PacketIO.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,6 +27,7 @@ import org.connectbot.sshlib.protocol.UnencryptedPacket import org.slf4j.LoggerFactory import java.io.ByteArrayOutputStream import java.nio.ByteBuffer +import java.security.MessageDigest import kotlin.random.Random /** @@ -321,7 +322,7 @@ internal class PacketIO(private val transport: Transport) { // Verify MAC (over plaintext for encrypt-and-MAC) val expectedMac = mac.compute(receiveSequenceNumber, decryptedPacket) - if (!receivedMac.contentEquals(expectedMac)) { + if (!MessageDigest.isEqual(receivedMac, expectedMac)) { logger.error("MAC verification failed for seq=$receiveSequenceNumber") logger.error(" Received MAC: ${receivedMac.joinToString("") { "%02x".format(it) }}") logger.error(" Expected MAC: ${expectedMac.joinToString("") { "%02x".format(it) }}") @@ -361,7 +362,7 @@ internal class PacketIO(private val transport: Transport) { // Verify MAC (over sequence_number || length || encrypted_payload) val expectedMac = mac.computeEtm(receiveSequenceNumber, encryptedLength, encryptedPayload) - if (!receivedMac.contentEquals(expectedMac)) { + if (!MessageDigest.isEqual(receivedMac, expectedMac)) { logger.error("ETM MAC verification failed for seq=$receiveSequenceNumber") throw TransportException("ETM MAC verification failed") } diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/LocalChannelWindowTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/LocalChannelWindowTest.kt new file mode 100644 index 0000000..7452f16 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/LocalChannelWindowTest.kt @@ -0,0 +1,94 @@ +/* + * ConnectBot SSH Library + * Copyright 2025-2026 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.SshException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +class LocalChannelWindowTest { + + @Test + fun `consumeLocal rejects data exceeding window`() { + val w = LocalChannelWindow(initialSize = 1024, adjustThreshold = 0) + assertFailsWith { w.consumeLocal(1025) } + } + + @Test + fun `consumeLocal accepts data exactly filling window`() { + val w = LocalChannelWindow(initialSize = 1024, adjustThreshold = 0) + val adjust = w.consumeLocal(1024) + assertEquals(0, adjust) + } + + @Test + fun `consumeLocal returns adjust when below threshold`() { + val w = LocalChannelWindow(initialSize = 1024, adjustThreshold = 512) + val adjust = w.consumeLocal(600) + assertEquals(1024 - (1024 - 600), adjust) + } + + @Test + fun `consumeLocal returns 0 when above threshold`() { + val w = LocalChannelWindow(initialSize = 1024, adjustThreshold = 64) + val adjust = w.consumeLocal(100) + assertEquals(0, adjust) + } + + @Test + fun `adjustRemote rejects zero`() { + val w = LocalChannelWindow(initialSize = 1024) + assertFailsWith { w.adjustRemote(0) } + } + + @Test + fun `adjustRemote rejects negative`() { + val w = LocalChannelWindow(initialSize = 1024) + assertFailsWith { w.adjustRemote(-1) } + } + + @Test + fun `adjustRemote rejects overflow past uint32 max`() { + val w = LocalChannelWindow(initialSize = 1024, remoteInitial = 64 * 1024L) + val overflow = LocalChannelWindow.MAX_WINDOW_SIZE - 64 * 1024L + 1L + assertFailsWith { w.adjustRemote(overflow) } + } + + @Test + fun `adjustRemote accepts value reaching exactly uint32 max`() { + val w = LocalChannelWindow(initialSize = 1024, remoteInitial = 64 * 1024L) + val maxAdd = LocalChannelWindow.MAX_WINDOW_SIZE - 64 * 1024L + w.adjustRemote(maxAdd) + assertEquals(LocalChannelWindow.MAX_WINDOW_SIZE, w.remoteRemaining) + } + + @Test + fun `consumeRemote decrements remote window`() { + val w = LocalChannelWindow(initialSize = 1024, remoteInitial = 1000L) + w.consumeRemote(300) + assertEquals(700L, w.remoteRemaining) + } + + @Test + fun `sendChunkSize handles uint32 remote window without overflow`() { + val w = LocalChannelWindow(initialSize = 1024, remoteInitial = LocalChannelWindow.MAX_WINDOW_SIZE) + + assertEquals(32768, w.sendChunkSize(64 * 1024, 32768)) + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SessionChannelTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SessionChannelTest.kt index 5f98257..c4df211 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SessionChannelTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SessionChannelTest.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,11 +25,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import org.connectbot.sshlib.SshException import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith @OptIn(ExperimentalCoroutinesApi::class) class SessionChannelTest { @@ -157,4 +159,67 @@ class SessionChannelTest { coVerify(exactly = 1) { conn.sendChannelClose(1) } } + + @Test + fun `onWindowAdjust throws when remote window would exceed max uint32`() = runTest { + // Start with a large initial window and try to push it over 2^32-1 + val (channel, _) = createChannel( + connection = mockk(relaxed = true), + initialWindowSize = 64 * 1024, + ) + // channel starts with remoteWindowSizeInitial = 64 * 1024; add enough to overflow uint32 + val overflow = (0xFFFFFFFFL - 64 * 1024L) + 1L + assertFailsWith { + channel.onWindowAdjust(overflow) + } + } + + @Test + fun `onWindowAdjust accepts legitimate adjustment within uint32 range`() = runTest { + val (channel, _) = createChannel( + connection = mockk(relaxed = true), + initialWindowSize = 64 * 1024, + ) + // Valid: bring window up to exactly 2^32-1 + val maxAdd = 0xFFFFFFFFL - 64 * 1024L + channel.onWindowAdjust(maxAdd) // should not throw + } + + @Test + fun `onWindowAdjust rejects zero adjustment`() = runTest { + val (channel, _) = createChannel(connection = mockk(relaxed = true)) + assertFailsWith { + channel.onWindowAdjust(0L) + } + } + + @Test + fun `onWindowAdjust rejects negative adjustment`() = runTest { + val (channel, _) = createChannel(connection = mockk(relaxed = true)) + assertFailsWith { + channel.onWindowAdjust(-1L) + } + } + + @Test + fun `onData rejects data exceeding local window size`() = runTest { + val (channel, _) = createChannel(initialWindowSize = 1024) + assertFailsWith { + channel.onData(ByteArray(1025)) + } + } + + @Test + fun `onData accepts data exactly filling local window`() = runTest { + val (channel, _) = createChannel(initialWindowSize = 1024) + channel.onData(ByteArray(1024)) + } + + @Test + fun `onExtendedData rejects data exceeding local window size`() = runTest { + val (channel, _) = createChannel(initialWindowSize = 512) + assertFailsWith { + channel.onExtendedData(1, ByteArray(513)) + } + } } diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/DiffieHellmanGroupExchangeTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/DiffieHellmanGroupExchangeTest.kt index 2fb58c2..6d7457c 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/DiffieHellmanGroupExchangeTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/DiffieHellmanGroupExchangeTest.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,6 +34,77 @@ import kotlin.test.assertTrue class DiffieHellmanGroupExchangeTest { + // A tiny 16-bit prime — far below the min=2048 requested by the client + private val tinyPrime = BigInteger("65537") + + @Test + fun `setGroup rejects p smaller than min bits`() { + val gex = DiffieHellmanGroupExchange("SHA-256") + assertFailsWith { + gex.setGroup(tinyPrime, DhGroups.GENERATOR) + } + } + + @Test + fun `setGroup rejects p larger than max bits`() { + // Construct a number just over 8192 bits + val tooBig = BigInteger.ONE.shiftLeft(8193) + val gex = DiffieHellmanGroupExchange("SHA-256") + assertFailsWith { + gex.setGroup(tooBig, DhGroups.GENERATOR) + } + } + + @Test + fun `setGroup rejects g equal to 1`() { + val gex = DiffieHellmanGroupExchange("SHA-256") + assertFailsWith { + gex.setGroup(DhGroups.GROUP14_P, BigInteger.ONE) + } + } + + @Test + fun `setGroup rejects g equal to 0`() { + val gex = DiffieHellmanGroupExchange("SHA-256") + assertFailsWith { + gex.setGroup(DhGroups.GROUP14_P, BigInteger.ZERO) + } + } + + @Test + fun `setGroup rejects g greater than or equal to p minus 1`() { + val gex = DiffieHellmanGroupExchange("SHA-256") + assertFailsWith { + gex.setGroup(DhGroups.GROUP14_P, DhGroups.GROUP14_P - BigInteger.ONE) + } + } + + @Test + fun `setGroup rejects even p`() { + val evenP = DhGroups.GROUP14_P.subtract(BigInteger.ONE) // make it even + val gex = DiffieHellmanGroupExchange("SHA-256") + assertFailsWith { + gex.setGroup(evenP, DhGroups.GENERATOR) + } + } + + @Test + fun `setGroup accepts valid group 14 parameters`() { + val gex = DiffieHellmanGroupExchange("SHA-256") + gex.setGroup(DhGroups.GROUP14_P, DhGroups.GENERATOR) + // Should not throw; verify we can generate keys + val e = gex.generateClientKeys() + assertTrue(e.isNotEmpty()) + } + + @Test + fun `setGroup accepts valid group 16 parameters`() { + val gex = DiffieHellmanGroupExchange("SHA-256") + gex.setGroup(DhGroups.GROUP16_P, DhGroups.GENERATOR) + val e = gex.generateClientKeys() + assertTrue(e.isNotEmpty()) + } + @Test fun `generateClientKeys throws if group not set`() { val gex = DiffieHellmanGroupExchange("SHA-256") 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 9d68e87..2ccbf08 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchangeTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchangeTest.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -125,6 +125,15 @@ class EcdhKeyExchangeTest { } } + @Test + fun `rejects all-zero shared secret`() { + val ecdh = EcdhKeyExchange("nistp256") + ecdh.generateClientKeys() + assertFailsWith { + ecdh.computeSharedSecretFromRaw(ByteArray(32)) + } + } + @Test fun `rejects unknown curve`() { assertFailsWith { diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/SignatureVerifierTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/SignatureVerifierTest.kt new file mode 100644 index 0000000..8364b7e --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/SignatureVerifierTest.kt @@ -0,0 +1,135 @@ +/* + * ConnectBot SSH Library + * Copyright 2025-2026 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.Test +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.math.BigInteger +import java.security.KeyPairGenerator +import java.security.Signature +import java.security.interfaces.RSAPublicKey +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SignatureVerifierTest { + + private fun encodeString(out: DataOutputStream, value: ByteArray) { + out.writeInt(value.size) + out.write(value) + } + + private fun encodeString(out: DataOutputStream, value: String) = encodeString(out, value.toByteArray(Charsets.US_ASCII)) + + private fun encodeMpint(out: DataOutputStream, value: BigInteger) { + val bytes = value.toByteArray() + out.writeInt(bytes.size) + out.write(bytes) + } + + private fun buildRsaHostKey(pub: RSAPublicKey): ByteArray { + val buf = ByteArrayOutputStream() + val out = DataOutputStream(buf) + encodeString(out, "ssh-rsa") + encodeMpint(out, pub.publicExponent) + encodeMpint(out, pub.modulus) + return buf.toByteArray() + } + + private fun buildSignatureBlob(algorithmName: String, sigBytes: ByteArray): ByteArray { + val buf = ByteArrayOutputStream() + val out = DataOutputStream(buf) + encodeString(out, algorithmName) + encodeString(out, sigBytes) + return buf.toByteArray() + } + + private fun signData(data: ByteArray, jcaAlgorithm: String, kp: java.security.KeyPair): ByteArray { + val sig = Signature.getInstance(jcaAlgorithm) + sig.initSign(kp.private) + sig.update(data) + return sig.sign() + } + + @Test + fun `accepts rsa-sha2-256 signature when rsa-sha2-256 was negotiated`() { + val kp = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val data = "exchange hash".toByteArray() + val sigBytes = signData(data, "SHA256withRSA", kp) + val hostKey = buildRsaHostKey(kp.public as RSAPublicKey) + val sigBlob = buildSignatureBlob("rsa-sha2-256", sigBytes) + + assertTrue(SignatureVerifier.verify(hostKey, sigBlob, data, "rsa-sha2-256")) + } + + @Test + fun `accepts rsa-sha2-512 signature when rsa-sha2-512 was negotiated`() { + val kp = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val data = "exchange hash".toByteArray() + val sigBytes = signData(data, "SHA512withRSA", kp) + val hostKey = buildRsaHostKey(kp.public as RSAPublicKey) + val sigBlob = buildSignatureBlob("rsa-sha2-512", sigBytes) + + assertTrue(SignatureVerifier.verify(hostKey, sigBlob, data, "rsa-sha2-512")) + } + + @Test + fun `rejects ssh-rsa signature blob when rsa-sha2-256 was negotiated`() { + val kp = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val data = "exchange hash".toByteArray() + // Server signs correctly with SHA-1, but client negotiated SHA-256 + val sigBytes = signData(data, "SHA1withRSA", kp) + val hostKey = buildRsaHostKey(kp.public as RSAPublicKey) + val sigBlob = buildSignatureBlob("ssh-rsa", sigBytes) + + assertFalse(SignatureVerifier.verify(hostKey, sigBlob, data, "rsa-sha2-256")) + } + + @Test + fun `rejects rsa-sha2-256 signature blob when rsa-sha2-512 was negotiated`() { + val kp = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val data = "exchange hash".toByteArray() + val sigBytes = signData(data, "SHA256withRSA", kp) + val hostKey = buildRsaHostKey(kp.public as RSAPublicKey) + val sigBlob = buildSignatureBlob("rsa-sha2-256", sigBytes) + + assertFalse(SignatureVerifier.verify(hostKey, sigBlob, data, "rsa-sha2-512")) + } + + @Test + fun `rejects rsa-sha2-512 signature blob when rsa-sha2-256 was negotiated`() { + val kp = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val data = "exchange hash".toByteArray() + val sigBytes = signData(data, "SHA512withRSA", kp) + val hostKey = buildRsaHostKey(kp.public as RSAPublicKey) + val sigBlob = buildSignatureBlob("rsa-sha2-512", sigBytes) + + assertFalse(SignatureVerifier.verify(hostKey, sigBlob, data, "rsa-sha2-256")) + } + + @Test + fun `rejects completely wrong algorithm name in signature blob`() { + val kp = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val data = "exchange hash".toByteArray() + val sigBytes = signData(data, "SHA256withRSA", kp) + val hostKey = buildRsaHostKey(kp.public as RSAPublicKey) + val sigBlob = buildSignatureBlob("unknown-algo", sigBytes) + + assertFalse(SignatureVerifier.verify(hostKey, sigBlob, data, "rsa-sha2-256")) + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/ZlibCompressorTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/ZlibCompressorTest.kt index db61ada..7f8e95f 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/ZlibCompressorTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/ZlibCompressorTest.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,10 +17,12 @@ package org.connectbot.sshlib.crypto +import org.connectbot.sshlib.SshException import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import kotlin.random.Random +import kotlin.test.assertFailsWith class ZlibCompressorTest { @@ -111,6 +113,34 @@ class ZlibCompressorTest { assertEquals(data.size, decompressed.size) } + @Test + fun `rejects decompression bomb exceeding limit`() { + val compressor = ZlibCompressor() + val decompressor = ZlibCompressor() + + // Highly compressible data: 1 MB of zeros compresses to ~1 KB + val bomb = ByteArray(1_024 * 1_024) + val compressed = compressor.compress(bomb) + + // The compressed form is small, but decompressing it must be rejected + // when the output would exceed MAX_UNCOMPRESSED_SIZE + assertFailsWith { + decompressor.uncompress(compressed) + } + } + + @Test + fun `accepts decompressed output within limit`() { + val compressor = ZlibCompressor() + val decompressor = ZlibCompressor() + + // Half of the limit — should succeed + val data = ByteArray(ZlibCompressor.MAX_UNCOMPRESSED_SIZE / 2) { it.toByte() } + val compressed = compressor.compress(data) + val result = decompressor.uncompress(compressed) + assertArrayEquals(data, result) + } + @Test fun smallInputFitsInInitialBuffer() { val compressor = ZlibCompressor() diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/transport/PacketIOEtmTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/transport/PacketIOEtmTest.kt index 4b3f1c8..b7104f8 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/transport/PacketIOEtmTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/transport/PacketIOEtmTest.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import org.connectbot.sshlib.crypto.HmacSha256 import org.connectbot.sshlib.crypto.TripleDesCbcCipher import org.connectbot.sshlib.protocol.SshEnums import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test class PacketIOEtmTest { @@ -173,6 +174,41 @@ class PacketIOEtmTest { assertEquals(SshEnums.MessageType.SSH_MSG_NEWKEYS, parsed.messageType()) } + @Test + fun `ETM rejects tampered MAC`() { + val (cipherKey, iv, macKey) = createKeyMaterial() + + val writeTransport = ByteArrayTransport() + val writeIO = PacketIO(writeTransport) + writeIO.enableEncryption( + clientToServerCipher = AesCbcCipher(cipherKey, iv.copyOf(), forEncryption = true), + clientToServerMac = HmacSha256(macKey.copyOf()), + serverToClientCipher = AesCbcCipher(cipherKey, iv.copyOf(), forEncryption = false), + serverToClientMac = HmacSha256(macKey.copyOf()), + clientToServerEtm = true, + serverToClientEtm = true, + ) + runBlocking { writeIO.writePacket(SshEnums.MessageType.SSH_MSG_NEWKEYS.id().toInt()) } + + val wireData = writeTransport.getWrittenData().clone() + wireData[wireData.size - 1] = (wireData[wireData.size - 1].toInt() xor 0xFF).toByte() + + val readTransport = ByteArrayTransport(wireData) + val readIO = PacketIO(readTransport) + readIO.enableEncryption( + clientToServerCipher = AesCbcCipher(cipherKey, iv.copyOf(), forEncryption = true), + clientToServerMac = HmacSha256(macKey.copyOf()), + serverToClientCipher = AesCbcCipher(cipherKey, iv.copyOf(), forEncryption = false), + serverToClientMac = HmacSha256(macKey.copyOf()), + clientToServerEtm = true, + serverToClientEtm = true, + ) + + assertThrows(TransportException::class.java) { + runBlocking { readIO.readPacket() } + } + } + @Test fun `ETM multiple packets round trip with CBC`() = runBlocking { val (cipherKey, iv, macKey) = createKeyMaterial() diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/transport/PacketIOTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/transport/PacketIOTest.kt index e4a6267..ff16217 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/transport/PacketIOTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/transport/PacketIOTest.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,8 @@ package org.connectbot.sshlib.transport import kotlinx.coroutines.runBlocking +import org.connectbot.sshlib.crypto.AesCbcCipher +import org.connectbot.sshlib.crypto.HmacSha256 import org.connectbot.sshlib.protocol.SshEnums import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows @@ -169,6 +171,39 @@ class PacketIOTest { assertEquals(0L, io.bytesReceivedOnWire) } + @Test + fun `encrypt-and-MAC rejects tampered MAC`() { + val cipherKey = ByteArray(16) { it.toByte() } + val iv = ByteArray(16) { (it + 0x10).toByte() } + val macKey = ByteArray(32) { (it + 0x20).toByte() } + + val writeTransport = ByteArrayTransport() + val writeIO = PacketIO(writeTransport) + writeIO.enableEncryption( + clientToServerCipher = AesCbcCipher(cipherKey, iv.copyOf(), forEncryption = true), + clientToServerMac = HmacSha256(macKey.copyOf()), + serverToClientCipher = AesCbcCipher(cipherKey, iv.copyOf(), forEncryption = false), + serverToClientMac = HmacSha256(macKey.copyOf()), + ) + runBlocking { writeIO.writePacket(SshEnums.MessageType.SSH_MSG_NEWKEYS.id().toInt()) } + + val wireData = writeTransport.getWrittenData().clone() + wireData[wireData.size - 1] = (wireData[wireData.size - 1].toInt() xor 0xFF).toByte() + + val readTransport = ByteArrayTransport(wireData) + val readIO = PacketIO(readTransport) + readIO.enableEncryption( + clientToServerCipher = AesCbcCipher(cipherKey, iv.copyOf(), forEncryption = true), + clientToServerMac = HmacSha256(macKey.copyOf()), + serverToClientCipher = AesCbcCipher(cipherKey, iv.copyOf(), forEncryption = false), + serverToClientMac = HmacSha256(macKey.copyOf()), + ) + + assertThrows(TransportException::class.java) { + runBlocking { readIO.readPacket() } + } + } + // calculatePaddingLength: totalLength = 4 + 1 + (1 + payload.size) = 6 + payload.size. // With blockSize=8: raw = if (totalLength%8==0) 8 else 8-(totalLength%8). Bump by 8 if raw<4. // Verifying the exact padding_length byte at wire[4] kills MathMutator and ConditionalsBoundaryMutator survivors.