From fd359b0e2b58b04cfd45ab4cc9829456b372296e Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Sun, 3 May 2026 00:37:16 -0700 Subject: [PATCH] feat: keystroke obfuscation Modeled after OpenSSH keystroke obfuscation "send chaff" technique to assist in defeating keystroke timing attacks. --- sshlib/api.txt | 7 +- .../kotlin/org/connectbot/sshlib/SshClient.kt | 1 + .../org/connectbot/sshlib/SshClientConfig.kt | 12 + .../sshlib/client/KeystrokeObfuscator.kt | 108 +++++ .../sshlib/client/SessionChannel.kt | 108 ++++- .../connectbot/sshlib/client/SshConnection.kt | 18 + .../org/connectbot/sshlib/SshClientTest.kt | 20 + .../sshlib/client/KeystrokeObfuscationTest.kt | 380 ++++++++++++++++++ .../sshlib/client/KeystrokeObfuscatorTest.kt | 152 +++++++ 9 files changed, 803 insertions(+), 3 deletions(-) create mode 100644 sshlib/src/main/kotlin/org/connectbot/sshlib/client/KeystrokeObfuscator.kt create mode 100644 sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeystrokeObfuscationTest.kt create mode 100644 sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeystrokeObfuscatorTest.kt diff --git a/sshlib/api.txt b/sshlib/api.txt index b02e7cce..c0beca32 100644 --- a/sshlib/api.txt +++ b/sshlib/api.txt @@ -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(); @@ -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; @@ -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(); @@ -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); @@ -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; @@ -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); method public suspend java.lang.Object? authenticatePassword(java.lang.String username, java.lang.String password, kotlin.coroutines.Continuation); method public suspend java.lang.Object? close(kotlin.coroutines.Continuation); diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt index 2097e87c..2c41704c 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt @@ -162,6 +162,7 @@ class SshClient private constructor( preferPasswordAuth = config.preferPasswordAuth, rekeyIntervalMs = config.rekeyIntervalMs, rekeyBytesLimit = config.rekeyBytesLimit, + obscureKeystrokeTimingIntervalMs = config.obscureKeystrokeTimingIntervalMs, ) val result = sshConnection.connect() diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClientConfig.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClientConfig.kt index 5d47bf0c..1c71a1d9 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClientConfig.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClientConfig.kt @@ -56,6 +56,7 @@ class SshClientConfig private constructor( val preferPasswordAuth: Boolean, val rekeyIntervalMs: Long, val rekeyBytesLimit: Long, + val obscureKeystrokeTimingIntervalMs: Long, ) { class Builder { /** @@ -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 + 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" } @@ -146,6 +157,7 @@ class SshClientConfig private constructor( preferPasswordAuth, rekeyIntervalMs, rekeyBytesLimit, + obscureKeystrokeTimingIntervalMs, ) } } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/KeystrokeObfuscator.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/KeystrokeObfuscator.kt new file mode 100644 index 00000000..ccbc1d16 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/KeystrokeObfuscator.kt @@ -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, +) { + 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) + } +} 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 6e6738d2..d7451396 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SessionChannel.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SessionChannel.kt @@ -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 @@ -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, @@ -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) @@ -59,6 +69,21 @@ class SessionChannel internal constructor( override val stdout: ReceiveChannel get() = _stdout override val stderr: ReceiveChannel 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(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 @@ -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 { @@ -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() } @@ -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? = _extendedData.receiveCatching().getOrNull() @@ -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, @@ -199,6 +299,8 @@ class SessionChannel internal constructor( ptyReq._check() msg.setRequestSpecificFields(ptyReq) } + if (granted) ptyGranted = true + return granted } override suspend fun requestShell(): Boolean { @@ -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() 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 c81d2960..dba12cd4 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt @@ -160,6 +160,7 @@ class SshConnection( private val preferPasswordAuth: Boolean = false, private val rekeyIntervalMs: Long = 3_600_000L, private val rekeyBytesLimit: Long = 1_073_741_824L, + private val obscureKeystrokeTimingIntervalMs: Long = 20L, coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { @@ -2426,6 +2427,21 @@ class SshConnection( ) } + /** + * Sends a chaff SSH_MSG_PING packet sized to match a minimal SSH_MSG_CHANNEL_DATA frame + * (4-byte channel ID + 4-byte length + 1-byte data = 9 bytes payload). Used for keystroke + * timing obfuscation in interactive sessions. + */ + internal suspend fun sendChaff() { + if (!serverSupportsPing || isRekeying) return + // SSH string encoding adds a 4-byte length, so 5 data bytes produce a 9-byte ping body. + val payload = "PING!".encodeToByteArray() + val ping = SshMsgPing() + ping.setData(createByteString(payload)) + ping._check() + writePacket(SshEnums.MessageType.SSH_MSG_PING.id().toInt(), ping.toByteArray()) + } + internal suspend fun sendWindowAdjust(recipientChannel: Int, bytesToAdd: Int) { val msg = SshMsgChannelWindowAdjust().apply { setRecipientChannel(recipientChannel.toLong()) @@ -2592,6 +2608,8 @@ class SshConnection( maxPacketSize, remoteWindowSizeInitial = remoteWindow, initialWindowSize = initialWindowSize, + canSendChaff = serverSupportsPing, + obscureKeystrokeTimingIntervalMs = obscureKeystrokeTimingIntervalMs, ) channels[localChannelNumber] = channel channelsByRemote[localChannelNumber] = channel diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/SshClientTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/SshClientTest.kt index aa783a44..ab54d40c 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/SshClientTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/SshClientTest.kt @@ -131,6 +131,15 @@ class SshClientTest { assertEquals(1_073_741_824L, config.rekeyBytesLimit) } + @Test + fun `SshClientConfig defaults keystroke obfuscation interval to twenty milliseconds`() { + val config = SshClientConfig { + host = "example.com" + hostKeyVerifier = acceptAllVerifier + } + assertEquals(20L, config.obscureKeystrokeTimingIntervalMs) + } + @Test fun `SshClientConfig custom rekey thresholds are applied`() { val config = SshClientConfig { @@ -194,4 +203,15 @@ class SshClientTest { } } } + + @Test + fun `SshClientConfig rejects negative keystroke obfuscation interval`() { + assertFailsWith { + SshClientConfig { + host = "example.com" + hostKeyVerifier = acceptAllVerifier + obscureKeystrokeTimingIntervalMs = -1L + } + } + } } diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeystrokeObfuscationTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeystrokeObfuscationTest.kt new file mode 100644 index 00000000..5605973f --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeystrokeObfuscationTest.kt @@ -0,0 +1,380 @@ +/* + * 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 io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.connectbot.sshlib.HostKeyVerifier +import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.crypto.AesCtrCipher +import org.connectbot.sshlib.crypto.HmacSha256 +import org.connectbot.sshlib.transport.PacketIO +import org.connectbot.sshlib.transport.Transport +import org.connectbot.sshlib.transport.TransportException +import org.junit.jupiter.api.Test +import kotlin.random.Random +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class KeystrokeObfuscationTest { + + private data class WireWrite(val timeMs: Long, val size: Int) + + private class RecordingTransport( + private val currentTimeMs: () -> Long, + ) : Transport { + val writes = mutableListOf() + private var connected = true + + override suspend fun read(count: Int): ByteArray = throw TransportException("RecordingTransport does not support reads") + + override suspend fun write(data: ByteArray) { + if (!connected) throw TransportException("Transport closed") + writes += WireWrite(currentTimeMs(), data.size) + } + + override suspend fun close() { + connected = false + } + + override val isConnected: Boolean + get() = connected + } + + private val acceptAllVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean = true + } + + private fun createObfuscatingChannel( + connection: SshConnection, + canSendChaff: Boolean, + intervalMs: Long, + scope: CoroutineScope, + obfuscatorClockMs: () -> Long = { System.nanoTime() / 1_000_000L }, + obfuscatorRandom: Random = Random.Default, + ): SessionChannel = SessionChannel( + connection = connection, + connectionScope = scope, + localChannelNumber = 0, + _remoteChannelNumber = 1, + maxPacketSize = 32 * 1024, + remoteWindowSizeInitial = 64 * 1024L, + initialWindowSize = 64 * 1024, + canSendChaff = canSendChaff, + obscureKeystrokeTimingIntervalMs = intervalMs, + obfuscatorClockMs = obfuscatorClockMs, + obfuscatorRandom = obfuscatorRandom, + ) + + @Test + fun `write without PTY does not send chaff`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val conn = mockk(relaxed = true) + coEvery { conn.sendChannelRequest(any(), any(), any(), any()) } returns true + + val channel = createObfuscatingChannel( + conn, + canSendChaff = true, + intervalMs = 20L, + scope = CoroutineScope(dispatcher), + ) + + // No PTY requested — write directly + channel.write("a".toByteArray()) + advanceUntilIdle() + + coVerify(exactly = 0) { conn.sendChaff() } + coVerify(exactly = 1) { conn.sendChannelData(any(), any()) } + } + + @Test + fun `write with PTY but canSendChaff false does not send chaff`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val conn = mockk(relaxed = true) + coEvery { conn.sendChannelRequest(any(), any(), any(), any()) } returns true + + val channel = createObfuscatingChannel( + conn, + canSendChaff = false, + intervalMs = 20L, + scope = CoroutineScope(dispatcher), + ) + channel.markPtyGranted() + + channel.write("a".toByteArray()) + advanceUntilIdle() + + coVerify(exactly = 0) { conn.sendChaff() } + coVerify(exactly = 1) { conn.sendChannelData(any(), any()) } + } + + @Test + fun `write with PTY and intervalMs zero does not send chaff`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val conn = mockk(relaxed = true) + coEvery { conn.sendChannelRequest(any(), any(), any(), any()) } returns true + + val channel = createObfuscatingChannel( + conn, + canSendChaff = true, + intervalMs = 0L, + scope = CoroutineScope(dispatcher), + ) + channel.markPtyGranted() + + channel.write("a".toByteArray()) + advanceUntilIdle() + + coVerify(exactly = 0) { conn.sendChaff() } + coVerify(exactly = 1) { conn.sendChannelData(any(), any()) } + } + + @Test + fun `first obfuscated write is sent immediately`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val conn = mockk(relaxed = true) + val sendTimes = mutableListOf() + coEvery { conn.sendChannelData(any(), any()) } coAnswers { + sendTimes += testScheduler.currentTime + Unit + } + + val channel = createObfuscatingChannel( + conn, + canSendChaff = true, + intervalMs = 20L, + scope = CoroutineScope(dispatcher), + ) + channel.markPtyGranted() + + channel.write("a".toByteArray()) + + assertEquals(listOf(0L), sendTimes) + } + + @Test + fun `write with PTY canSendChaff and interval sends chaff during chaff window`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val conn = mockk(relaxed = true) + coEvery { conn.sendChannelRequest(any(), any(), any(), any()) } returns true + + val channel = createObfuscatingChannel( + conn, + canSendChaff = true, + intervalMs = 20L, + scope = CoroutineScope(dispatcher), + ) + channel.markPtyGranted() + + channel.write("a".toByteArray()) + // Advance past chaff window (CHAFF_MIN_MS=100 + up to CHAFF_RANGE_MS=400 ms) + testScheduler.advanceTimeBy(600L) + advanceUntilIdle() + + // At least one chaff packet should have been sent + coVerify(atLeast = 1) { conn.sendChaff() } + coVerify(exactly = 1) { conn.sendChannelData(any(), any()) } + } + + @Test + fun `chaff restarts for a later typing burst`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val conn = mockk(relaxed = true) + var chaffCount = 0 + coEvery { conn.sendChaff() } coAnswers { + chaffCount++ + Unit + } + + val channel = createObfuscatingChannel( + conn, + canSendChaff = true, + intervalMs = 20L, + scope = CoroutineScope(dispatcher), + ) + channel.markPtyGranted() + + channel.write("a".toByteArray()) + testScheduler.advanceTimeBy(600L) + advanceUntilIdle() + val firstBurstChaffCount = chaffCount + assertTrue(firstBurstChaffCount > 0, "Expected chaff during first burst") + + channel.write("b".toByteArray()) + testScheduler.advanceTimeBy(600L) + advanceUntilIdle() + + assertTrue(chaffCount > firstBurstChaffCount, "Expected chaff to restart for second burst") + } + + @Test + fun `encrypted keystroke and chaff packets have same size and twenty millisecond cadence`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val transport = RecordingTransport { testScheduler.currentTime } + val connection = SshConnection( + transport = transport, + hostKeyVerifier = acceptAllVerifier, + rekeyIntervalMs = Long.MAX_VALUE, + rekeyBytesLimit = Long.MAX_VALUE, + coroutineDispatcher = dispatcher, + ) + connection.serverSupportsPing = true + enableTestEncryption(connection) + + val channel = createObfuscatingChannel( + connection, + canSendChaff = true, + intervalMs = 20L, + scope = backgroundScope, + obfuscatorClockMs = { testScheduler.currentTime }, + obfuscatorRandom = Random(0), + ) + channel.markPtyGranted() + + channel.write("a".toByteArray()) + backgroundScope.launch(dispatcher) { + delay(7) + channel.write("b".toByteArray()) + } + testScheduler.advanceTimeBy(140) + + val writes = transport.writes + assertTrue(writes.size >= 5, "Expected keystrokes plus chaff, got $writes") + assertEquals( + setOf(64), + writes.map { it.size }.toSet(), + "Single-byte CHANNEL_DATA and chaff PING packets must have identical encrypted wire sizes", + ) + + val intervals = writes.zipWithNext { a, b -> b.timeMs - a.timeMs } + assertTrue( + intervals.all { it in 15L..25L }, + "Expected encrypted keystroke/chaff packets roughly 20ms apart, got writes=$writes intervals=$intervals", + ) + + channel.close() + connection.close() + } + + @Test + fun `chaff stops after chaff window expires`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val conn = mockk(relaxed = true) + coEvery { conn.sendChannelRequest(any(), any(), any(), any()) } returns true + var chaffCount = 0 + coEvery { conn.sendChaff() } coAnswers { + chaffCount++ + Unit + } + + val channel = createObfuscatingChannel( + conn, + canSendChaff = true, + intervalMs = 20L, + scope = CoroutineScope(dispatcher), + ) + channel.markPtyGranted() + + channel.write("a".toByteArray()) + testScheduler.advanceTimeBy(600L) + advanceUntilIdle() + + // Record chaff count at this point. + coVerify(atLeast = 1) { conn.sendChaff() } + val chaffCountAfterWindow = chaffCount + + // Wait much longer — no more chaff should be sent + testScheduler.advanceTimeBy(5_000L) + advanceUntilIdle() + + assertEquals(chaffCountAfterWindow, chaffCount) + } + + @Test + fun `requestPty sets ptyGranted on success`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val conn = mockk(relaxed = true) + coEvery { conn.sendChannelRequest(any(), eq("pty-req"), any(), any()) } returns true + + val channel = createObfuscatingChannel( + conn, + canSendChaff = true, + intervalMs = 20L, + scope = CoroutineScope(dispatcher), + ) + + val result = channel.requestPty("xterm", 80, 24, 0, 0, byteArrayOf()) + assertTrue(result) + + // Now write — should activate obfuscation (chaff will be sent) + channel.write("a".toByteArray()) + testScheduler.advanceTimeBy(600L) + advanceUntilIdle() + + coVerify(atLeast = 1) { conn.sendChaff() } + } + + @Test + fun `requestPty does not set ptyGranted on failure`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val conn = mockk(relaxed = true) + coEvery { conn.sendChannelRequest(any(), eq("pty-req"), any(), any()) } returns false + + val channel = createObfuscatingChannel( + conn, + canSendChaff = true, + intervalMs = 20L, + scope = CoroutineScope(dispatcher), + ) + + val result = channel.requestPty("xterm", 80, 24, 0, 0, byteArrayOf()) + assertFalse(result) + + channel.write("a".toByteArray()) + advanceUntilIdle() + + coVerify(exactly = 0) { conn.sendChaff() } + } + + private fun enableTestEncryption(connection: SshConnection) { + val packetIoField = SshConnection::class.java.getDeclaredField("packetIO") + packetIoField.isAccessible = true + val packetIO = packetIoField.get(connection) as PacketIO + + val cipherKey = ByteArray(16) { it.toByte() } + val iv = ByteArray(16) { (it + 0x10).toByte() } + val macKey = ByteArray(32) { (it + 0x20).toByte() } + + packetIO.enableEncryption( + clientToServerCipher = AesCtrCipher(cipherKey.copyOf(), iv.copyOf(), forEncryption = true), + clientToServerMac = HmacSha256(macKey.copyOf()), + serverToClientCipher = AesCtrCipher(cipherKey.copyOf(), iv.copyOf(), forEncryption = false), + serverToClientMac = HmacSha256(macKey.copyOf()), + ) + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeystrokeObfuscatorTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeystrokeObfuscatorTest.kt new file mode 100644 index 00000000..effdd569 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/KeystrokeObfuscatorTest.kt @@ -0,0 +1,152 @@ +/* + * 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.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 + +class KeystrokeObfuscatorTest { + + private val intervalMs = 20L + + @Test + fun `initially not active`() { + val obfuscator = KeystrokeObfuscator(intervalMs, clockMs = { 0L }) + assertFalse(obfuscator.isActive()) + } + + @Test + fun `rejects non-positive interval`() { + assertFailsWith { + KeystrokeObfuscator(0L) + } + assertFailsWith { + KeystrokeObfuscator(-1L) + } + } + + @Test + fun `becomes active on first keystroke`() { + var now = 0L + val obfuscator = KeystrokeObfuscator(intervalMs, clockMs = { now }) + obfuscator.recordKeystroke() + assertTrue(obfuscator.isActive()) + } + + @Test + fun `delayUntilNextSendMs is bounded on first keystroke at start`() { + var now = 0L + val obfuscator = KeystrokeObfuscator(intervalMs, clockMs = { now }) + obfuscator.recordKeystroke() + // The first keystroke starts the session; delay until the first interval + // boundary from 'now'. Since we just started, we should get a small positive value. + val delay = obfuscator.delayUntilNextSendMs() + assertTrue(delay >= 0, "Delay should be non-negative, was $delay") + // Delay must be within one interval (plus fuzz) of starting + assertTrue(delay <= intervalMs * 2, "Delay $delay should be within 2 intervals") + } + + @Test + fun `delayUntilNextSendMs is zero when interval has elapsed`() { + var now = 0L + val obfuscator = KeystrokeObfuscator(intervalMs, clockMs = { now }) + obfuscator.recordKeystroke() + // Advance time past the next interval + now = intervalMs * 3 + val delay = obfuscator.delayUntilNextSendMs() + assertEquals(0L, delay, "Delay should be 0 when interval has elapsed") + } + + @Test + fun `chaffUntilMs extends after recordKeystroke at a later time`() { + var now = 0L + val obfuscator = KeystrokeObfuscator(intervalMs, clockMs = { now }) + obfuscator.recordKeystroke() + val chaffUntil1 = obfuscator.chaffUntilMs() + assertTrue(chaffUntil1 > 0, "chaffUntilMs should be positive after first keystroke") + + // Advance time well past the chaff window and record another keystroke. + // The new chaffUntilMs is computed from the new 'now', so it will be larger. + now = chaffUntil1 + intervalMs * 10 + obfuscator.recordKeystroke() + val chaffUntil2 = obfuscator.chaffUntilMs() + assertTrue(chaffUntil2 > chaffUntil1, "chaffUntilMs should extend after subsequent keystroke at later time") + } + + @Test + fun `isActive becomes false after chaff window expires`() { + var now = 0L + val obfuscator = KeystrokeObfuscator(intervalMs, clockMs = { now }) + obfuscator.recordKeystroke() + assertTrue(obfuscator.isActive()) + + // Advance past chaff window + now = obfuscator.chaffUntilMs() + 1 + assertFalse(obfuscator.isActive()) + } + + @Test + fun `stop deactivates immediately`() { + var now = 0L + val obfuscator = KeystrokeObfuscator(intervalMs, clockMs = { now }) + obfuscator.recordKeystroke() + assertTrue(obfuscator.isActive()) + obfuscator.stop() + assertFalse(obfuscator.isActive()) + } + + @Test + fun `nextIntervalMs advances on advanceInterval when time has moved past it`() { + var now = 0L + val obfuscator = KeystrokeObfuscator(intervalMs, clockMs = { now }) + obfuscator.recordKeystroke() + val first = obfuscator.nextIntervalMs() + // Advance clock to the next interval boundary before calling advanceInterval + now = first + obfuscator.advanceInterval() + val second = obfuscator.nextIntervalMs() + assertTrue(second > first, "Next interval should advance after advanceInterval()") + } + + @Test + fun `fuzz keeps interval within expected bounds`() { + // Run many iterations to verify fuzz stays within [0.9x, 1.1x + sessionRate] of interval + var now = 0L + val obfuscator = KeystrokeObfuscator(intervalMs, clockMs = { now }) + obfuscator.recordKeystroke() + + repeat(100) { + val prev = obfuscator.nextIntervalMs() + now = prev + obfuscator.advanceInterval() + val next = obfuscator.nextIntervalMs() + val elapsed = next - prev + // Allow up to 2x due to session rate fuzz, but must be at least 80% of interval + assertTrue( + elapsed >= intervalMs * 80L / 100L, + "Interval $elapsed was below 80% of expected $intervalMs", + ) + assertTrue( + elapsed <= intervalMs * 220L / 100L, + "Interval $elapsed exceeded 220% of expected $intervalMs", + ) + } + } +}