Files
alknet/docs/research/references/ssh/russh/03-ssh-protocol-implementation.md

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-sha256curve25519-sha256curve25519-sha256@libssh.orgdiffie-hellman-group-exchange-sha256 → group18/17/16/15/14
  • Host Key: ssh-ed25519ecdsa-sha2-nistp256/384/521rsa-sha2-512/256ssh-rsa
  • Cipher: chacha20-poly1305@openssh.comaes256-gcm@openssh.comaes256-ctraes192-ctraes128-ctr
  • MAC: hmac-sha2-512-etm@openssh.comhmac-sha2-256-etm@openssh.comhmac-sha2-512hmac-sha2-256

Negotiation flow (write_kex / read_kex):

  1. Both sides send KEXINIT with their algorithm lists
  2. Each side parses the other's KEXINIT using Client::read_kex() or Server::read_kex()
  3. The Select trait implements the first-match algorithm
  4. Names struct is produced with the negotiated algorithms
  5. 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:

  1. Client sends KEX_ECDH_INIT: Contains client's ephemeral public key
  2. Server sends KEX_ECDH_REPLY: Contains server host key, server's ephemeral public key, and a signature
  3. 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
  4. 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:

  1. Client sends KEX_DH_GEX_REQUEST with min/preferred/max group sizes
  2. Server sends KEX_DH_GEX_GROUP with the prime and generator
  3. Client validates group size and sends KEX_DH_GEX_INIT
  4. Server sends KEX_DH_GEX_REPLY
  5. 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 key
  • D = server-to-client encryption key
  • E = client-to-server MAC key
  • F = 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:

  1. Client sends SERVICE_REQUEST for "ssh-connection"
  2. Server responds with SERVICE_ACCEPT
  3. Client sends authentication requests (password, publickey, keyboard-interactive, none)
  4. Server responds with USERAUTH_SUCCESS, USERAUTH_FAILURE, or USERAUTH_INFO_REQUEST
  5. Upon success, state transitions to InitCompressionAuthenticated

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 KEXINIT packets 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_size and recipient_window_size
  • Data cannot be sent if the recipient's window is exhausted
  • CHANNEL_WINDOW_ADJUST messages increase the window
  • adjust_window() is called when data is received to determine the next target window
  • Data is queued in pending_data when the window is full, and flushed when the window is adjusted

Channel Request/Response Pattern

Many channel operations follow a request-response pattern:

  1. Client sends CHANNEL_REQUEST with want_reply = true
  2. Server processes the request via Handler callback
  3. Server calls session.channel_success(channel) or session.channel_failure(channel)
  4. Client receives CHANNEL_SUCCESS or CHANNEL_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 by Handle::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?;