Skip to content
25 changes: 13 additions & 12 deletions sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentChannel.kt
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
Expand All @@ -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<Unit>(Channel.CONFLATED)

val isOpen: Boolean get() = _isOpen
Expand All @@ -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)

Expand All @@ -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)
}
}
Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<Unit>(Channel.CONFLATED)

private val _incomingData = Channel<ByteArray>(Channel.UNLIMITED)
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<Unit>(Channel.CONFLATED)

private val _stdout = Channel<ByteArray>(Channel.UNLIMITED)
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading