docs(research): add russh and sftp-rs deep-dive references

This commit is contained in:
2026-06-10 13:41:17 +00:00
parent 5bb5e1064c
commit f2a25f5bc1
15 changed files with 3908 additions and 0 deletions

View File

@@ -0,0 +1,430 @@
# Russh: Internal Architecture & Data Flow
This document covers the internal mechanics — session state machines, the event loop, packet handling, buffering, and the interaction between components.
## Session State
### `CommonSession<C>`
The shared session state used by both client and server:
```rust
pub(crate) struct CommonSession<C> {
pub auth_user: String,
pub remote_sshid: Vec<u8>,
pub config: C, // Arc<client::Config> or Arc<server::Config>
pub encrypted: Option<Encrypted>,
pub auth_method: Option<auth::Method>, // Client only
pub auth_attempts: usize,
pub packet_writer: PacketWriter,
pub remote_to_local: Box<dyn OpeningKey + Send>,
pub wants_reply: bool,
pub disconnected: bool,
pub buffer: Vec<u8>, // Incoming packet scratch buffer
pub strict_kex: bool,
pub alive_timeouts: usize,
pub received_data: bool,
}
```
### `Encrypted`
The state after encryption keys are established:
```rust
pub(crate) struct Encrypted {
pub state: EncryptedState,
pub exchange: Option<Exchange>,
pub kex: KexAlgorithm,
pub key: usize,
pub client_mac: mac::Name,
pub server_mac: mac::Name,
pub session_id: CryptoVec, // Constant across rekeys
pub channels: HashMap<ChannelId, ChannelParams>,
pub last_channel_id: Wrapping<u32>,
pub write: Vec<u8>, // Outgoing packet assembly buffer
pub write_cursor: usize, // Current position in write buffer
pub last_rekey: Instant,
pub server_compression: Compression,
pub client_compression: Compression,
pub decompress: Decompress,
pub rekey_wanted: bool,
pub received_extensions: Vec<String>,
pub extension_info_awaiters: HashMap<String, Vec<oneshot::Sender<()>>>,
}
```
### `EncryptedState`
```rust
pub enum EncryptedState {
WaitingAuthServiceRequest { sent: bool, accepted: bool },
WaitingAuthRequest(auth::AuthRequest),
InitCompression,
Authenticated,
}
```
### `Exchange`
Protocol values collected during key exchange (all non-secret, visible on wire):
```rust
pub struct Exchange {
pub client_id: Vec<u8>,
pub server_id: Vec<u8>,
pub client_kex_init: Vec<u8>,
pub server_kex_init: Vec<u8>,
pub client_ephemeral: Vec<u8>,
pub server_ephemeral: Vec<u8>,
pub gex: Option<(GexParams, DhGroup)>,
}
```
### `NewKeys`
Produced when key exchange completes, contains everything needed to activate encryption:
```rust
pub(crate) struct NewKeys {
pub exchange: Exchange,
pub names: negotiation::Names,
pub kex: KexAlgorithm,
pub key: usize,
pub cipher: CipherPair, // { local_to_remote, remote_to_local }
pub session_id: CryptoVec,
}
```
### `ChannelParams`
Internal channel state (not exposed to users):
```rust
pub(crate) struct ChannelParams {
pub recipient_channel: u32, // Remote channel ID
pub sender_channel: ChannelId, // Local channel ID
pub recipient_window_size: u32,
pub sender_window_size: u32,
pub recipient_maximum_packet_size: u32,
pub sender_maximum_packet_size: u32,
pub confirmed: bool, // Whether server confirmed the channel
pub wants_reply: bool,
pub pending_data: VecDeque<(Bytes, Option<u32>, usize)>, // (data, ext_code, offset)
pub pending_eof: bool,
pub pending_close: bool,
}
```
---
## Client Event Loop
The client event loop is the core of `client::Session::run_inner()`:
```rust
async fn run_inner<H, R>(&mut self, stream_read, stream_write, handler, kex_done_signal)
-> Result<RemoteDisconnectInfo, H::Error>
{
// Initial setup
self.flush()?;
self.common.packet_writer.flush_into(stream_write).await?;
// Set up timers
let keepalive_timer = ...;
let inactivity_timer = ...;
let reading = start_reading(stream_read, buffer, opening_cipher);
while !self.common.disconnected {
tokio::select! {
// 1. Incoming SSH packet
r = &mut reading => {
// Decrypt and decompress packet
// Process DISCONNECT or pass to reply()
// Restart reading
}
// 2. Keepalive timer
() = &mut keepalive_timer => {
// Send keepalive if authenticated
// Track timeout count
}
// 3. Inactivity timer
() = &mut inactivity_timer => {
// Return InactivityTimeout error
}
// 4. Outgoing messages from Handle
msg = self.receiver.recv(), if !self.kex.active() => {
// Process message (auth, channel open, data, etc.)
// Batch all pending outgoing messages
}
// 5. Inbound channel messages
msg = self.inbound_channel_receiver.recv(), if !self.kex.active() => {
// Process channel data/eof/close
// Batch all pending messages
}
};
// Flush all pending writes
self.flush()?;
self.common.packet_writer.flush_into(stream_write).await?;
// Handle deferred compression after authentication
if EncryptedState::InitCompression { ... } {
// Init client compression if deferred (zlib@openssh.com)
// Transition to Authenticated
}
// Reset timers if data received or keepalive sent
}
}
```
### Key Event Loop Behaviors
1. **Kex blocking**: When `self.kex.active()` is true, outgoing messages from `Handle` are NOT processed. This prevents sending data during key exchange.
2. **Batching**: After receiving one message from `receiver`, all pending messages are drained with `try_recv()` to batch writes.
3. **Keepalive management**: Keepalive timer resets when data is received. `alive_timeouts` tracks consecutive unanswered keepalives.
4. **Compression activation**: `zlib@openssh.com` compression is deferred until after authentication succeeds (handled by `InitCompression` state).
---
## Server Event Loop
Similar to the client, but the server accepts connections via `run_on_socket()` or `run_stream()`:
```rust
// Server::run_on_socket
loop {
tokio::select! {
_ = shutdown_rx.recv() => { /* Graceful shutdown */ },
accept_result = socket.accept() => {
// For each connection:
// 1. Create a new Handler via Server::new_client()
// 2. Call run_stream() in a spawned task
// 3. Wait for session or shutdown
},
error = error_rx.recv() => {
// Report session errors
}
}
}
```
### `run_stream`
```rust
pub async fn run_stream<H, R>(config, stream, handler) -> Result<RunningSession<H>, H::Error>
{
// 1. Write server SSH ID
// 2. Read client SSH ID
// 3. Create Session with CommonSession state
// 4. Begin initial rekey (sends KEXINIT)
// 5. Spawn session.run() in a task
// 6. Return RunningSession (implements Future)
}
```
### `RunningServer` and `RunningServerHandle`
The server returns a `RunningServer` that implements `Future` and a `RunningServerHandle` for graceful shutdown:
```rust
pub struct RunningServer<F: Future<Output = io::Result<()>> + Unpin + Send> {
inner: F,
shutdown_tx: broadcast::Sender<String>,
}
impl RunningServerHandle {
pub fn shutdown(&self, reason: String) {
let _ = self.shutdown_tx.send(reason);
}
}
```
---
## Packet Handling Pipeline
### Reading Packets (`cipher::read`)
```
1. Read 4 bytes (or more for block ciphers) → encrypted packet length
2. Decrypt packet length
3. Parse length, check against MAXIMUM_PACKET_LEN (262159 bytes)
4. Read remaining bytes (length + tag_len - already_read)
5. Decrypt the ciphertext (including tag verification for AEAD)
6. Remove padding
7. Increment sequence number
```
### Writing Packets (`SealingKey::write`)
```
1. Compute padding length (block-aligned, min 4 bytes)
2. Write: [packet_length (4B)] [padding_length (1B)] [payload] [padding] [tag]
3. Encrypt the packet
4. Compute and append MAC/tag
5. Increment sequence number
6. Add payload bytes to rekey counter
```
### Packet Assembly (`PacketWriter`)
```rust
pub(crate) struct PacketWriter {
cipher: Box<dyn SealingKey + Send>,
compress: Compress,
compress_buffer: Vec<u8>,
write_buffer: SSHBuffer,
}
```
Methods:
- `packet_raw(buf)`: Compress and encrypt a raw packet
- `packet(f)`: Build a packet via closure, compress, encrypt, return the plaintext
- `flush_into(w)`: Write all buffered data to the async writer
- `set_cipher(c)`: Swap the cipher (for rekeying)
- `reset_seqn()`: Reset sequence number (for strict kex)
### `push_packet!` Macro
Used throughout for building packets:
```rust
macro_rules! push_packet {
( $buffer:expr, $x:expr ) => {{
let i0 = $buffer.len();
$buffer.extend(b"\0\0\0\0"); // Placeholder for length
let x = $x; // Build the packet body
let i1 = $buffer.len();
BigEndian::write_u32(&mut buf[i0..], (i1 - i0 - 4) as u32);
x
}};
}
```
---
## Channel Data Flow
### Client-side Channel Creation
```
1. Handle::channel_open_session()
→ Creates ChannelRef with mpsc::channel
→ Sends Msg::ChannelOpenSession to session
2. Session::handle_msg(Msg::ChannelOpenSession)
→ Calls self.channel_open_session()
→ Sends CHANNEL_OPEN packet with channel type "session"
→ Stores ChannelRef in self.channels
3. Server responds with CHANNEL_OPEN_CONFIRMATION
→ Session updates ChannelParams (recipient_channel, window sizes)
→ Sends ChannelMsg::Open through ChannelRef's sender
4. wait_channel_confirmation() receives the Open message
→ Creates Channel { read_half, write_half }
→ Returns the Channel to the caller
```
### Data Transmission (Client → Server)
```
1. channel.data(reader)
→ Uses ChannelTx (AsyncWrite) that reads from the reader
→ Sends ChannelMsg::Data through the channel sender
→ Waits for window availability via WindowSizeRef
2. Session receives ChannelMsg::Data
→ Calls Encrypted::data(channel, bytes, is_rekeying)
→ If pending data exists or rekeying: queues in pending_data
→ Otherwise: writes CHANNEL_DATA packet immediately
→ If window exhausted: queues remaining data
3. PacketWriter encrypts and buffers the packet
4. Event loop flushes the buffer to the TCP stream
```
### Data Reception (Server → Client)
```
1. Encrypted packet arrives, decrypted, decompressed
2. Packet type = CHANNEL_DATA
3. Encrypted::adjust_window_size() checks if window needs adjustment
4. ChannelMsg::Data sent through ChannelRef's sender
5. Channel::wait() returns the ChannelMsg::Data
6. ChannelRx (AsyncRead) reads from the channel receiver
```
---
## Rekeying Flow
```
1. Trigger:
- Bytes written/read exceed limits
- Time since last rekey exceeds limit
- Explicit: Handle::rekey_soon()
- Server-initiated: receiving KEXINIT when idle
2. Session::begin_rekey():
- Creates new ClientKex/ServerKex
- Sends new KEXINIT
- Sets kex state to InProgress
3. During rekey:
- Outgoing messages from Handle are blocked (!kex.active())
- Incoming non-kex packets are buffered in pending_reads
- Kex state machine processes kex packets (KEXINIT, DH init/reply, NEWKEYS)
4. On completion:
- Flush all pending channel data
- Process buffered pending_reads
- Call CommonSession::newkeys() to swap ciphers
- Reset byte counters
- Set kex state to Idle
- Resume processing outgoing messages
```
---
## Sub-Crates
### `russh-cryptovec` (cryptovec/)
A `Vec<u8>` alternative that:
- Zeroes memory on drop and reallocation (via `memset` / `ExplicitZero`)
- Locks memory pages with `mlock` (Unix) / `VirtualLock` (Windows) to prevent swapping
- Uses `unsafe` for performance-critical operations (copying, initialization)
- Integrates with `ssh-encoding` for Encode support
Used for all sensitive data: session keys, shared secrets, MAC keys, exchange hashes.
### `russh-util` (russh-util/)
Runtime abstraction layer:
- `russh_util::runtime::spawn()` — spawns a task (tokio or wasm)
- `russh_util::runtime::JoinHandle` — task join handle
- `russh_util::time::Instant` — time source (tokio or chrono for WASM)
- WASM compatibility: uses `wasm-bindgen-futures` and `chrono` when target is `wasm32`
### `russh-config` (russh-config/)
SSH config file parser:
- Parses `~/.ssh/config` format
- Supports `Host` matching with `globset`
- Provides `Stream::tcp_connect()` and `Stream::proxy_command()` for establishing connections based on config
### `pageant` (pageant/)
Windows Pageant SSH agent transport:
- `wmmessage` feature: Classic Pageant protocol via Windows messages
- `namedpipes` feature: Modern Pageant protocol via named pipes (PuTTY ≥ 0.75)