Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion sshlib/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ package org.connectbot.sshlib {
method @InaccessibleFromKotlin public org.connectbot.sshlib.HostKeyVerifier getHostKeyVerifier();
method @InaccessibleFromKotlin public java.lang.String getKexAlgorithms();
method @InaccessibleFromKotlin public java.lang.String getMacAlgorithms();
method @InaccessibleFromKotlin public long getObscureKeystrokeTimingIntervalMs();
method @InaccessibleFromKotlin public boolean getPreferPasswordAuth();
method @InaccessibleFromKotlin public long getRekeyBytesLimit();
method @InaccessibleFromKotlin public long getRekeyIntervalMs();
Expand All @@ -568,6 +569,7 @@ package org.connectbot.sshlib {
property public org.connectbot.sshlib.HostKeyVerifier hostKeyVerifier;
property public String kexAlgorithms;
property public String macAlgorithms;
property public long obscureKeystrokeTimingIntervalMs;
property public boolean preferPasswordAuth;
property public long rekeyBytesLimit;
property public long rekeyIntervalMs;
Expand All @@ -587,6 +589,7 @@ package org.connectbot.sshlib {
method @InaccessibleFromKotlin public org.connectbot.sshlib.transport.IpVersion getIpVersion();
method @InaccessibleFromKotlin public java.lang.String getKexAlgorithms();
method @InaccessibleFromKotlin public java.lang.String getMacAlgorithms();
method @InaccessibleFromKotlin public long getObscureKeystrokeTimingIntervalMs();
method @InaccessibleFromKotlin public int getPort();
method @InaccessibleFromKotlin public boolean getPreferPasswordAuth();
method @InaccessibleFromKotlin public long getRekeyBytesLimit();
Expand All @@ -601,6 +604,7 @@ package org.connectbot.sshlib {
method @InaccessibleFromKotlin public void setIpVersion(org.connectbot.sshlib.transport.IpVersion);
method @InaccessibleFromKotlin public void setKexAlgorithms(java.lang.String);
method @InaccessibleFromKotlin public void setMacAlgorithms(java.lang.String);
method @InaccessibleFromKotlin public void setObscureKeystrokeTimingIntervalMs(long);
method @InaccessibleFromKotlin public void setPort(int);
method @InaccessibleFromKotlin public void setPreferPasswordAuth(boolean);
method @InaccessibleFromKotlin public void setRekeyBytesLimit(long);
Expand All @@ -615,6 +619,7 @@ package org.connectbot.sshlib {
property public org.connectbot.sshlib.transport.IpVersion ipVersion;
property public String kexAlgorithms;
property public String macAlgorithms;
property public long obscureKeystrokeTimingIntervalMs;
property public int port;
property public boolean preferPasswordAuth;
property public long rekeyBytesLimit;
Expand Down Expand Up @@ -753,7 +758,7 @@ package org.connectbot.sshlib.client {
}

public final class SshConnection {
ctor public SshConnection(org.connectbot.sshlib.transport.Transport transport, optional java.lang.String clientVersion, org.connectbot.sshlib.HostKeyVerifier hostKeyVerifier, optional java.lang.String kexAlgorithms, optional java.lang.String hostKeyAlgorithms, optional java.lang.String encryptionAlgorithms, optional java.lang.String macAlgorithms, optional java.lang.String compressionAlgorithms, optional boolean preferPasswordAuth, optional long rekeyIntervalMs, optional long rekeyBytesLimit, optional kotlinx.coroutines.CoroutineDispatcher coroutineDispatcher);
ctor public SshConnection(org.connectbot.sshlib.transport.Transport transport, optional java.lang.String clientVersion, org.connectbot.sshlib.HostKeyVerifier hostKeyVerifier, optional java.lang.String kexAlgorithms, optional java.lang.String hostKeyAlgorithms, optional java.lang.String encryptionAlgorithms, optional java.lang.String macAlgorithms, optional java.lang.String compressionAlgorithms, optional boolean preferPasswordAuth, optional long rekeyIntervalMs, optional long rekeyBytesLimit, optional long obscureKeystrokeTimingIntervalMs, optional kotlinx.coroutines.CoroutineDispatcher coroutineDispatcher);
method public suspend java.lang.Object? authenticateKeyboardInteractive(java.lang.String username, org.connectbot.sshlib.KeyboardInteractiveCallback callback, kotlin.coroutines.Continuation<? super org.connectbot.sshlib.AuthResult>);
method public suspend java.lang.Object? authenticatePassword(java.lang.String username, java.lang.String password, kotlin.coroutines.Continuation<? super org.connectbot.sshlib.AuthResult>);
method public suspend java.lang.Object? close(kotlin.coroutines.Continuation<? super kotlin.Unit>);
Expand Down
1 change: 1 addition & 0 deletions sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ class SshClient private constructor(
preferPasswordAuth = config.preferPasswordAuth,
rekeyIntervalMs = config.rekeyIntervalMs,
rekeyBytesLimit = config.rekeyBytesLimit,
obscureKeystrokeTimingIntervalMs = config.obscureKeystrokeTimingIntervalMs,
)
val result = sshConnection.connect()

Expand Down
12 changes: 12 additions & 0 deletions sshlib/src/main/kotlin/org/connectbot/sshlib/SshClientConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class SshClientConfig private constructor(
val preferPasswordAuth: Boolean,
val rekeyIntervalMs: Long,
val rekeyBytesLimit: Long,
val obscureKeystrokeTimingIntervalMs: Long,
) {
class Builder {
/**
Expand Down Expand Up @@ -119,12 +120,22 @@ class SshClientConfig private constructor(
*/
var rekeyBytesLimit: Long = 1_073_741_824L

/**
* Quantize outbound keystroke packet timing to this interval (milliseconds) to mask
* inter-keystroke timing patterns. Requires a PTY session and server support for
* `ping@openssh.com`. Set to 0 to disable.
*/
var obscureKeystrokeTimingIntervalMs: Long = 20L

Comment thread
kruton marked this conversation as resolved.
fun build(): SshClientConfig {
val factory = transportFactory ?: run {
require(host.isNotBlank()) { "Host must be specified when using default TCP transport" }
require(port in 1..65535) { "Port must be between 1 and 65535" }
KtorTcpTransportFactory(host, port, ipVersion)
}
require(obscureKeystrokeTimingIntervalMs >= 0) {
"obscureKeystrokeTimingIntervalMs must be non-negative"
}

val verifier = hostKeyVerifier
requireNotNull(verifier) { "hostKeyVerifier must be set" }
Expand All @@ -146,6 +157,7 @@ class SshClientConfig private constructor(
preferPasswordAuth,
rekeyIntervalMs,
rekeyBytesLimit,
obscureKeystrokeTimingIntervalMs,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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 kotlin.random.Random

/**
* Tracks timing state for keystroke obfuscation in interactive SSH sessions.
*
* Mirrors the algorithm in OpenSSH clientloop.c `obfuscate_keystroke_timing()` /
* `set_next_interval()`. Pure timing logic with no coroutine dependencies so it can be
* unit-tested directly.
*
* Chaff packets (SSH_MSG_PING) are sent between real keystrokes to fill timing gaps. The
* interval is fuzzed per-packet and a per-session rate offset is applied to make the average
* rate unpredictable.
*/
internal class KeystrokeObfuscator(
private val intervalMs: Long,
private val clockMs: () -> Long = { System.nanoTime() / 1_000_000L },
private val random: Random = Random.Default,
) {
Comment thread
kruton marked this conversation as resolved.
init {
require(intervalMs > 0) { "intervalMs must be greater than 0" }
}

companion object {
private const val FUZZ_PERCENT = 10L
private const val CHAFF_MIN_MS = 100L
private const val CHAFF_RANGE_MS = 400L
}

private var active = false
private var nextIntervalMs = 0L
private var _chaffUntilMs = 0L

// Per-session constant fuzz offset, set once when obfuscation starts.
private var sessionRate = 0L

fun isActive(): Boolean = active && clockMs() < _chaffUntilMs

fun chaffUntilMs(): Long = _chaffUntilMs

fun nextIntervalMs(): Long = nextIntervalMs

fun stop() {
active = false
}

/**
* Record a real keystroke.
*
* @return true when this keystroke started or restarted an obfuscation window.
*/
fun recordKeystroke(): Boolean {
val now = clockMs()
val justStarted = !active || now >= _chaffUntilMs
if (justStarted) {
active = true
setNextInterval(now, intervalMs, starting = true)
}
_chaffUntilMs = now + CHAFF_MIN_MS + random.nextLong(CHAFF_RANGE_MS)
return justStarted
}

/** Returns milliseconds to wait before the next send is allowed (0 if ready now). */
fun delayUntilNextSendMs(): Long {
val now = clockMs()
if (now >= nextIntervalMs) return 0L
return nextIntervalMs - now
}

/** Advance [nextIntervalMs] to the next boundary, accounting for missed intervals. */
fun advanceInterval() {
val now = clockMs()
var missedIntervals = (now - nextIntervalMs) / intervalMs
if (missedIntervals < 0) missedIntervals = 0
setNextInterval(now, intervalMs * (missedIntervals + 1), starting = false)
}

private fun computeFuzzMs(intervalMsValue: Long): Long {
var fuzz = intervalMsValue * FUZZ_PERCENT / 100L
if (fuzz <= 0L) fuzz = 1L
if (fuzz > Int.MAX_VALUE) fuzz = Int.MAX_VALUE.toLong()
return fuzz
}

private fun setNextInterval(now: Long, intervalMsValue: Long, starting: Boolean) {
val fuzz = computeFuzzMs(intervalMsValue)
if (starting) sessionRate = random.nextLong(fuzz)
val adjusted = intervalMsValue - fuzz + random.nextLong(fuzz) + sessionRate
nextIntervalMs = now + adjusted.coerceAtLeast(1L)
}
}
108 changes: 106 additions & 2 deletions sshlib/src/main/kotlin/org/connectbot/sshlib/client/SessionChannel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@
package org.connectbot.sshlib.client

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.connectbot.sshlib.SshSession
import org.connectbot.sshlib.protocol.ByteString
import org.connectbot.sshlib.protocol.ChannelRequestExec
Expand All @@ -28,6 +32,8 @@ import org.connectbot.sshlib.protocol.ChannelRequestShell
import org.connectbot.sshlib.protocol.ChannelRequestSubsystem
import org.connectbot.sshlib.protocol.ChannelRequestWindowChange
import org.slf4j.LoggerFactory
import java.util.concurrent.atomic.AtomicInteger
import kotlin.random.Random

class SessionChannel internal constructor(
private val connection: SshConnection,
Expand All @@ -37,6 +43,10 @@ class SessionChannel internal constructor(
private val maxPacketSize: Int,
remoteWindowSizeInitial: Long = 0,
private val initialWindowSize: Int = 64 * 1024,
private val canSendChaff: Boolean = false,
private val obscureKeystrokeTimingIntervalMs: Long = 20L,
private val obfuscatorClockMs: () -> Long = { System.nanoTime() / 1_000_000L },
private val obfuscatorRandom: Random = Random.Default,
) : SshSession {
companion object {
private val logger = LoggerFactory.getLogger(SessionChannel::class.java)
Expand All @@ -59,6 +69,21 @@ class SessionChannel internal constructor(
override val stdout: ReceiveChannel<ByteArray> get() = _stdout
override val stderr: ReceiveChannel<ByteArray> get() = _stderr

private var ptyGranted = false
private var obfuscator: KeystrokeObfuscator? = null
private val obfuscatorMutex = Mutex()
private var chaffJob: Job? = null
private val pendingObfuscatedWrites = AtomicInteger(0)
private val obfuscatedWritesIdle = Channel<Unit>(Channel.CONFLATED)

/** Called by tests that need to simulate PTY being granted without a real channel request. */
internal fun markPtyGranted() {
ptyGranted = true
}

private val obfuscationActive: Boolean
get() = ptyGranted && canSendChaff && obscureKeystrokeTimingIntervalMs > 0

internal suspend fun onData(data: ByteArray) {
_stdout.trySend(data)
localWindowSize -= data.size
Expand Down Expand Up @@ -99,6 +124,10 @@ class SessionChannel internal constructor(

internal suspend fun onClose() {
logger.debug("Received CLOSE on channel $localChannelNumber")
obfuscatorMutex.withLock {
obfuscator?.stop()
}
chaffJob?.cancel()
if (!closeSent) {
closeSent = true
try {
Expand All @@ -115,9 +144,16 @@ class SessionChannel internal constructor(
}

override suspend fun write(data: ByteArray) {
if (obfuscationActive) {
writeObfuscated(data)
} else {
writeDirect(data)
}
}

private suspend fun writeDirect(data: ByteArray) {
var offset = 0
while (offset < data.size) {
// Wait for remote window to have space
while (remoteWindowSize <= 0) {
windowAvailable.receive()
}
Expand All @@ -133,6 +169,70 @@ class SessionChannel internal constructor(
}
}

private suspend fun writeObfuscated(data: ByteArray) {
pendingObfuscatedWrites.incrementAndGet()
try {
val (obs, justStarted) = obfuscatorMutex.withLock {
val current = obfuscator ?: KeystrokeObfuscator(
obscureKeystrokeTimingIntervalMs,
clockMs = obfuscatorClockMs,
random = obfuscatorRandom,
).also {
obfuscator = it
}
current to current.recordKeystroke()
}
startChaffLoopIfNeeded(obs)

if (!justStarted) {
val delayMs = obfuscatorMutex.withLock {
obs.delayUntilNextSendMs()
}
if (delayMs > 0) {
delay(delayMs)
}
obfuscatorMutex.withLock {
obs.advanceInterval()
}
}

writeDirect(data)
} finally {
if (pendingObfuscatedWrites.decrementAndGet() == 0) {
obfuscatedWritesIdle.trySend(Unit)
}
}
}

private fun startChaffLoopIfNeeded(obs: KeystrokeObfuscator) {
if (chaffJob?.isActive == true) return
chaffJob = connectionScope.launch {
while (obfuscatorMutex.withLock { obs.isActive() }) {
val delayMs = obfuscatorMutex.withLock {
obs.delayUntilNextSendMs()
}
if (delayMs > 0) {
delay(delayMs)
}
val shouldSendChaff = obfuscatorMutex.withLock {
if (!obs.isActive()) {
false
} else if (pendingObfuscatedWrites.get() > 0) {
null
} else {
obs.advanceInterval()
true
}
}
when (shouldSendChaff) {
true -> connection.sendChaff()
false -> break
null -> obfuscatedWritesIdle.receiveCatching()
}
}
}
}

override suspend fun read(): ByteArray? = _stdout.receiveCatching().getOrNull()

override suspend fun readExtended(): Pair<Int, ByteArray>? = _extendedData.receiveCatching().getOrNull()
Expand Down Expand Up @@ -172,7 +272,7 @@ class SessionChannel internal constructor(
terminalModes: ByteArray,
): Boolean {
logger.debug("Requesting PTY: $terminalType ${widthChars}x$heightRows")
return connection.sendChannelRequest(
val granted = connection.sendChannelRequest(
_remoteChannelNumber,
"pty-req",
wantReply = true,
Expand All @@ -199,6 +299,8 @@ class SessionChannel internal constructor(
ptyReq._check()
msg.setRequestSpecificFields(ptyReq)
}
if (granted) ptyGranted = true
return granted
}

override suspend fun requestShell(): Boolean {
Expand Down Expand Up @@ -254,6 +356,8 @@ class SessionChannel internal constructor(
if (!_isOpen) return

logger.debug("Closing channel $localChannelNumber")
obfuscator?.stop()
chaffJob?.cancel()
closeSent = true
_isOpen = false
_stdout.close()
Expand Down
Loading
Loading