11 KiB
Russh: SSH Protocol Implementation
This document covers how russh implements the SSH-2 protocol — key exchange, authentication, channel multiplexing, port forwarding, and extensions.
Protocol Message Constants (msg module)
Russh defines SSH protocol message type codes as constants:
| Constant | Value | RFC Reference |
|---|---|---|
DISCONNECT |
1 | RFC 4253 §11.1 |
IGNORE |
2 | RFC 4253 §11.2 |
UNIMPLEMENTED |
3 | RFC 4253 §11.3 |
DEBUG |
4 | RFC 4253 §11.4 |
SERVICE_REQUEST |
5 | RFC 4253 §7 |
SERVICE_ACCEPT |
6 | RFC 4253 §7 |
EXT_INFO |
7 | RFC 8308 |
KEXINIT |
20 | RFC 4253 §7.1 |
NEWKEYS |
21 | RFC 4253 §7.3 |
KEX_ECDH_INIT |
30 | RFC 5656 §4 |
KEX_ECDH_REPLY |
31 | RFC 5656 §4 |
KEX_DH_GEX_REQUEST |
34 | RFC 4419 §3 |
KEX_DH_GEX_GROUP |
31 | RFC 4419 §3 |
KEX_DH_GEX_INIT |
32 | RFC 4419 §3 |
KEX_DH_GEX_REPLY |
33 | RFC 4419 §3 |
USERAUTH_REQUEST |
50 | RFC 4252 §5 |
USERAUTH_FAILURE |
51 | RFC 4252 §5 |
USERAUTH_SUCCESS |
52 | RFC 4252 §5 |
USERAUTH_BANNER |
53 | RFC 4252 §5.4 |
USERAUTH_INFO_REQUEST |
60 | RFC 4256 §5 |
USERAUTH_INFO_RESPONSE |
61 | RFC 4256 §5 |
GLOBAL_REQUEST |
80 | RFC 4254 §4 |
REQUEST_SUCCESS |
81 | RFC 4254 §4 |
REQUEST_FAILURE |
82 | RFC 4254 §4 |
CHANNEL_OPEN |
90 | RFC 4254 §5.1 |
CHANNEL_OPEN_CONFIRMATION |
91 | RFC 4254 §5.1 |
CHANNEL_OPEN_FAILURE |
92 | RFC 4254 §5.1 |
CHANNEL_WINDOW_ADJUST |
93 | RFC 4254 §5.2 |
CHANNEL_DATA |
94 | RFC 4254 §5.2 |
CHANNEL_EXTENDED_DATA |
95 | RFC 4254 §5.2 |
CHANNEL_EOF |
96 | RFC 4254 §5.3 |
CHANNEL_CLOSE |
97 | RFC 4254 §5.3 |
CHANNEL_REQUEST |
98 | RFC 4254 §5.4 |
CHANNEL_SUCCESS |
99 | RFC 4254 §5.4 |
CHANNEL_FAILURE |
100 | RFC 4254 §5.4 |
Connection Lifecycle
1. Version Exchange
Both sides send their SSH identification string:
SSH-2.0-russh_0.60.2\r\n
The SshId type handles this:
pub enum SshId {
Standard(Cow<'static, str>), // Appends \r\n
Raw(Cow<'static, str>), // Sent as-is
}
Client sends first, then reads server's ID. Server reads client's ID first, then sends its own.
2. Key Exchange (KEXINIT)
The negotiation module handles algorithm negotiation. Each side sends a KEXINIT packet containing lists of supported algorithms. The first algorithm from the client's list that also appears in the server's list is chosen (for client), or vice versa (for server).
Algorithm selection order (per Preferred::DEFAULT):
- KEX:
mlkem768x25519-sha256→curve25519-sha256→curve25519-sha256@libssh.org→diffie-hellman-group-exchange-sha256→ group18/17/16/15/14 - Host Key:
ssh-ed25519→ecdsa-sha2-nistp256/384/521→rsa-sha2-512/256→ssh-rsa - Cipher:
chacha20-poly1305@openssh.com→aes256-gcm@openssh.com→aes256-ctr→aes192-ctr→aes128-ctr - MAC:
hmac-sha2-512-etm@openssh.com→hmac-sha2-256-etm@openssh.com→hmac-sha2-512→hmac-sha2-256
Negotiation flow (write_kex / read_kex):
- Both sides send KEXINIT with their algorithm lists
- Each side parses the other's KEXINIT using
Client::read_kex()orServer::read_kex() - The
Selecttrait implements the first-match algorithm Namesstruct is produced with the negotiated algorithms- Extension kex names (like
ext-info-c,kex-strict-c-v00@openssh.com) are handled specially — filtered from negotiation but used for capability detection
3. Diffie-Hellman Key Exchange
After KEXINIT, the actual key exchange runs. This is managed by ClientKex and ServerKex state machines.
Client KEX state machine (ClientKex):
Created → (receive KEXINIT) → WaitingForDhReply or WaitingForGexReply → WaitingForNewKeys → Done
Steps for a typical Curve25519 exchange:
- Client sends KEX_ECDH_INIT: Contains client's ephemeral public key
- Server sends KEX_ECDH_REPLY: Contains server host key, server's ephemeral public key, and a signature
- Client verifies:
- Parses the server host key
- Computes the shared secret from the ephemeral keys
- Computes the exchange hash (H) from:
V_C || V_S || I_C || I_S || K_S || e || f || K - Verifies the server's signature on H using the server host key
- Calls
handler.check_server_key()for application-level trust verification
- Both sides send NEWKEYS: Activate the newly computed encryption keys
Server KEX mirrors this, responding to client's init and sending the reply.
DH-GEX (Group Exchange) flow has an extra step:
- Client sends
KEX_DH_GEX_REQUESTwith min/preferred/max group sizes - Server sends
KEX_DH_GEX_GROUPwith the prime and generator - Client validates group size and sends
KEX_DH_GEX_INIT - Server sends
KEX_DH_GEX_REPLY - Both send
NEWKEYS
4. Key Derivation (compute_keys)
After the shared secret and exchange hash are computed, keys are derived per RFC 4253 §7.2:
Key = HASH(K || H || X || session_id)
Where X is a single character:
A= client-to-server IV (nonce)B= server-to-client IV (nonce)C= client-to-server encryption keyD= server-to-client encryption keyE= client-to-server MAC keyF= server-to-client MAC key
If the derived key is shorter than needed, it's extended by hashing K || H || Key[0..n] repeatedly.
The session_id is set to the exchange hash from the first key exchange and remains constant across rekeys.
5. Strict Key Exchange
Russh supports the OpenSSH strict key exchange extension (kex-strict-c-v00@openssh.com / kex-strict-s-v00@openssh.com), which:
- Resets sequence numbers after NEWKEYS
- Enforces that only KEXINIT, DH init, and NEWKEYS messages appear during initial kex
- Validated by
validate_client_msg_strict_kex/validate_server_msg_strict_kex - Prevents the Terrapin attack (CVE-2023-48799)
6. Authentication
After NEWKEYS, the encrypted state machine transitions through:
pub enum EncryptedState {
WaitingAuthServiceRequest { sent: bool, accepted: bool },
WaitingAuthRequest(auth::AuthRequest),
InitCompression,
Authenticated,
}
Flow:
- Client sends
SERVICE_REQUESTfor "ssh-connection" - Server responds with
SERVICE_ACCEPT - Client sends authentication requests (password, publickey, keyboard-interactive, none)
- Server responds with
USERAUTH_SUCCESS,USERAUTH_FAILURE, orUSERAUTH_INFO_REQUEST - Upon success, state transitions to
InitCompression→Authenticated
Authentication methods (auth::Method):
pub enum Method {
None,
Password { password: String },
PublicKey { key: PrivateKeyWithHashAlg },
OpenSshCertificate { key: Arc<PrivateKey>, cert: Certificate },
FuturePublicKey { key: PublicKey, hash_alg: Option<HashAlg> },
FutureCertificate { cert: Certificate, hash_alg: Option<HashAlg> },
KeyboardInteractive { submethods: String },
}
The FuturePublicKey / FutureCertificate variants are used with the Signer trait (e.g., SSH agent), where signing is delegated to an external component.
7. Rekeying
Rekeying is triggered automatically when:
- Bytes written exceed
Limits::rekey_write_limit(default 1 GB) - Bytes read exceed
Limits::rekey_read_limit(default 1 GB) - Time since last rekey exceeds
Limits::rekey_time_limit(default 1 hour) - Explicitly via
Handle::rekey_soon()
During rekey:
- New
KEXINITpackets are exchanged - Pending channel data is buffered until rekey completes
- Sequence numbers are reset if strict kex is negotiated
Channel Multiplexing
SSH channels are multiplexed over a single connection. Each channel has:
- A
ChannelId(local identifier) - A
recipient_channel(remote identifier) - Window size for flow control
- Maximum packet size
Channel Types
| Type | Open Method | Description |
|---|---|---|
session |
channel_open_session() |
Standard shell/exec channel |
x11 |
channel_open_x11() |
X11 forwarding |
direct-tcpip |
channel_open_direct_tcpip() |
Local TCP port forwarding |
forwarded-tcpip |
Server-initiated | Remote TCP port forwarding |
direct-streamlocal |
channel_open_direct_streamlocal() |
Local UNIX socket forwarding |
forwarded-streamlocal |
Server-initiated | Remote UNIX socket forwarding |
auth-agent@openssh.com |
Server-initiated | Agent forwarding |
Flow Control
SSH uses a window-based flow control mechanism:
- Each side maintains a
sender_window_sizeandrecipient_window_size - Data cannot be sent if the recipient's window is exhausted
CHANNEL_WINDOW_ADJUSTmessages increase the windowadjust_window()is called when data is received to determine the next target window- Data is queued in
pending_datawhen the window is full, and flushed when the window is adjusted
Channel Request/Response Pattern
Many channel operations follow a request-response pattern:
- Client sends
CHANNEL_REQUESTwithwant_reply = true - Server processes the request via
Handlercallback - Server calls
session.channel_success(channel)orsession.channel_failure(channel) - Client receives
CHANNEL_SUCCESSorCHANNEL_FAILURE
Port Forwarding
Local TCP Forwarding (direct-tcpip)
Client opens a channel to forward a TCP connection through the server:
// Client side
let channel = handle.channel_open_direct_tcpip(
"target-host", 8080, // host:port to connect to
"origin-host", 12345, // originator info
).await?;
Remote TCP Forwarding (forward-tcpip)
Step 1: Client requests the server to listen on a port:
let allocated_port = handle.tcpip_forward("0.0.0.0", 0).await?;
Step 2: When a connection arrives, the server calls Handler::channel_open_forwarded_tcpip().
Step 3: Client receives the channel via Handler::server_channel_open_forwarded_tcpip().
Step 4: To stop:
handle.cancel_tcpip_forward("0.0.0.0", allocated_port).await?;
UNIX Socket Forwarding (streamlocal)
Similar to TCP forwarding but for UNIX domain sockets:
// Local
handle.channel_open_direct_streamlocal("/tmp/socket").await?;
// Remote
handle.streamlocal_forward("/tmp/socket").await?;
handle.cancel_streamlocal_forward("/tmp/socket").await?;
Extensions
ext-info-c / ext-info-s (RFC 8308)
Extension info is sent after NEWKEYS. Russh supports:
server-sig-algs: Server tells client which signature algorithms it accepts for public key authentication. Used byHandle::best_supported_rsa_hash().
kex-strict-c-v00@openssh.com / kex-strict-s-v00@openssh.com
OpenSSH strict key exchange (Terrapin attack mitigation). Negotiated during KEXINIT and enforced during the initial key exchange.
OpenSSH Agent Forwarding
The server can open an auth-agent@openssh.com channel to forward the client's SSH agent. The client handles this via Handler::server_channel_open_agent_forward().
OpenSSH no-more-sessions@openssh.com
Requests that the other side not open any more sessions:
handle.no_more_sessions(true).await?;