This file covers architecture details, protocol serialization, concurrency rules, and coding guidelines for agents working on the internal implementation of the SSH library.
All supported cryptographic algorithms are declared in enum registries. Visibility is strictly managed to limit the public API.
CipherEntry,MacEntry,KexEntry: Public enums for choosing algorithms, but theircreate()factory methods areinternal.SignatureEntry: Public enum for host key algorithms. The underlyingalgorithmproperty isinternal.- Interfaces:
PacketMac,KexAlgorithm,PacketCipher,PacketAead,X25519Provider, andSshSignatureAlgorithmare allinternal.
To add a new algorithm: add an enum entry in Algorithms.kt. SshConnection and SshClientConfig consume the registries.
Kaitai Struct Compiler: Generates Java classes from .ksy protocol definitions.
- Module:
:protocol - Input:
protocol/src/main/resources/kaitai/*.ksy - Output:
protocol/build/generated/kaitai/org/connectbot/sshlib/protocol/ - The
kaitaitask automatically runs before compilation.
Implements SSH client connection lifecycle using KStateMachine.
- Internal Visibility: The state machine and its
SshClientCallbacksareinternal. - Refactored SshConnection:
SshConnectionno longer implementsSshClientCallbackspublicly. It uses a private inner object to handle state machine callbacks, ensuring protocol-level types (likeIdBannerorSshMsgKexinit) do not leak into the public API.
Generated classes are in package org.connectbot.sshlib.protocol.
val stream = ByteBufferKaitaiStream(byteArray)
val message = SomeSshMessage(stream)
message._read()
val field = message.someField()readU4be()→Long(unsigned 32-bit big-endian)readBytes(len: Long)→ByteArrayreadU1()→Int
Allocate a writable stream with ByteBufferKaitaiStream(capacityLong):
val stream = ByteBufferKaitaiStream(4L + payload.size)
stream.writeU4be(totalLength) // 4-byte big-endian length
stream.writeU1(messageType) // 1-byte message type
stream.writeBytes(payload)
stream.seek(0)
return stream.readBytesFull()Key write methods on KaitaiStream:
writeU4be(Long)— 4-byte big-endian unsigned intwriteU1(Int)— 1-byte unsignedwriteBytes(ByteArray)— raw byteswriteS4be(Int)— 4-byte big-endian signed int
Use the toByteArray() extension from KaitaiUtils.kt:
val msg = SomeSshMessage()
msg.setField(createByteString(data))
msg._check()
val bytes = msg.toByteArray()createByteString(ByteArray)— wraps a byte array as a Kaitai ByteString fieldcreateAsciiString(String)— wraps an ASCII stringcreateUtf8String(String)— wraps a UTF-8 string
Kaitai's AsciiString validates that the string is ≤ 65535 bytes. Do not use it for arbitrary-length binary blobs (e.g., public key blobs). Use ByteString instead.
- API Surface: Keep the public API surface minimal. Use
internalvisibility for all classes and interfaces that deal with protocol-level details (Kaitai structs, state machine callbacks, low-level crypto interfaces). - No Unchecked Exceptions: No unchecked exceptions should be thrown in the library.
- Pure logic extracted to top-level
internal funis directly unit-testable without annotation libraries. - Follow the
selectPasswordMethods,keyBlobAlgorithmName,buildAgentMessage, andisConstraintSatisfiedpatterns: extract from class body, pass state as parameters. - Use
EqualsVerifier.forClass(Foo::class.java).verify()to unit testhashCode/equalson any class that implements them. data classtypes withByteArrayfields needEqualsVerifier.withPrefabValues(ByteArray::class.java, ...).- Avoid
privateextension functions onByteArray?insidedata class— they causeStackOverflowErrorwith EqualsVerifier. UsecontentEquals()directly on non-null receivers.
This codebase has a carefully designed concurrency model. Violating these rules introduces races, deadlocks, or thread starvation.
stateMachineDispatcher is the single-threaded serialization point:
- All reads and writes of protocol state (
pendingAuth,pendingChannelOpen,pendingChannelRequest,pendingGlobalRequest,currentAuthMethod,authResultChannel,infoRequestChannel) must happen insidewithContext(stateMachineDispatcher). PendingValue.complete()andcompleteExceptionally()are non-suspendbut must only be called from within the dispatcher. The teardown path instartPacketLoopwraps its cleanup inwithContext(stateMachineDispatcher)for this reason.- When you need to atomically set a
PendingValueand send a packet or dispatch an event, usePendingValue.setDirect()inside a singlewithContext(stateMachineDispatcher)block. SeesendAuthRequest,openSessionChannel,sendChannelRequest, andsendTcpipForwardRequestfor the established pattern.
Never use runBlocking inside coroutine code:
processNextPacketand all its callees aresuspend. Callsuspendfunctions directly.- State machine callbacks that do I/O are declared
suspendinSshClientCallbacks. BlockingSshClientis the only legitimate use ofrunBlocking— it is the intentional blocking adapter at the edge of the system.
Channel and map thread safety:
- All four channel maps (
channels,channelsByRemote,agentChannels,agentChannelsByRemote) useConcurrentHashMap. UseConcurrentHashMapfor any new shared mutable maps accessed from both the packet loop and the caller's coroutine. - Use
Collections.synchronizedSet(orConcurrentHashMap.newKeySet()) for any shared mutable sets accessed from multiple coroutines.
Window flow control:
- Never busy-wait with
delay(N)for window availability. Use theChannel<Unit>(CONFLATED)pattern:windowAvailable.trySend(Unit)inonWindowAdjust,windowAvailable.receive()in the send loop. Close the channel in the close path to unblock blocked senders. remoteWindowSizemust be@Volatilebecause it is written byonWindowAdjust(packet loop coroutine) and read by send functions (caller's coroutine).
PendingValue<T> contract:
set(deferred)—suspend, enters dispatcher, safe from any context.setDirect(deferred)— non-suspend, must be called from withinstateMachineDispatcher.complete(value)— non-suspend, must be called from withinstateMachineDispatcher; returnsBoolean(true if a deferred was present).completeExceptionally(e)— non-suspend, must be called from withinstateMachineDispatcher.clearIfSame(expected)—suspend, safe from any context; use infinallyblocks to avoid clearing a deferred that has already been replaced by a subsequent request.