从零构建 SSH 服务器:协议握手、密钥交换与通道复用的 Rust 工程实战

从零构建 SSH 服务器:协议握手、密钥交换与通道复用的 Rust 工程实战

SSH(Secure Shell)是工程师日常连接远程服务器最基础的工具,但大多数开发者对它的认知停留在 ssh user@host 的命令行层面。实际上,SSH 协议是一套经过数十年考验的密码学工程杰作,从 RFC 4251 到 RFC 4254 定义了一套完整的传输层、认证层和连接层协议栈。本文将从零开始,用 Rust 实现一个最小可运行的 SSH 服务器,深入剖析 Diffie-Hellman 密钥协商、算法协商状态机、公钥认证、通道复用等核心机制,并讨论生产级实现中的工程权衡。

一、SSH 协议架构全景

SSH 协议采用分层设计,从底层到顶层依次为:


┌─────────────────────────────────┐
│       连接层 (Connection)        │  ← 通道复用、会话管理
├─────────────────────────────────┤
│     用户认证层 (Authentication)   │  ← password / publickey / keyboard-interactive
├─────────────────────────────────┤
│     传输层 (Transport)            │  ← 密钥交换、加密、完整性校验
├─────────────────────────────────┤
│       TCP 传输                    │  ← 端口 22
└─────────────────────────────────┘

传输层的职责是建立安全通道:完成服务端认证、协商加密/ MAC/ 压缩算法、执行密钥交换并派生会话密钥。认证层在已建立的安全通道上对客户端身份进行验证。连接层则实现 SSH 最强大的功能——将多条逻辑通道(session、direct-tcpip、forwarded-tcpip)复用在单一安全连接上。

我们的实现路径遵循自底向上的原则:先完成传输层握手,再实现公钥认证,最后构建通道机制。

二、传输层:版本协商与算法选择

连接建立后,双方首先交换版本字符串。这是一个明文交换过程:


客户端 → 服务端: SSH-2.0-my_ssh_server_0.1\r\n
服务端 → 客户端: SSH-2.0-OpenSSH_9.6\r\n

关键约束:版本行不能超过 255 字节,且必须以 \r\n 结束。服务端实现中需要注意限制读取的字节数,防止恶意客户端发送超长版本行导致内存耗尽。版本协商阶段不做任何密码学操作,因此必须限制这个阶段的处理时间。

版本交换完成后,双方同时发送 SSH_MSG_KEXINIT 报文,携带各自支持的算法列表。算法选择遵循"客户端优先"原则:对于每一类算法,结果取客户端列表与服务端列表的交集,客户端列表中出现次序靠前的优先被选中。算法类别包括:

  • kex(密钥交换算法):curve25519-sha256、diffie-hellman-group14-sha256
  • server_host_key(服务端主机密钥算法):rsa-sha2-255、rsa-sha2-512、ssh-ed25519
  • encryption(对称加密算法):aes256-gcm、chacha20-poly1305、aes256-ctr
  • mac(消息认证码):使用 AEAD 算法时为 aead,否则单独指定如 hmac-sha2-256
  • compression:none、zlib

Rust 实现算法协商时,可以定义一个结构体承载协商结果:


#[derive(Debug, Clone)]
struct KexResult {
    kex_alg: String,              // "curve25519-sha256"
    host_key_alg: String,         // "ssh-ed25519"
    enc_alg_c2s: String,          // client-to-server 加密
    enc_alg_s2c: String,          // server-to-client 加密
    mac_alg_c2s: String,
    mac_alg_s2c: String,
    comp_alg_c2s: String,
    comp_alg_s2c: String,
}

/// 算法协商:取客户端列表与服务端列表的交集,保持客户端顺序优先
fn negotiate_algorithm(client_prefs: &[&str], server_prefs: &[&str]) -> Option<String> {
    for alg in client_prefs {
        if server_prefs.contains(alg) {
            return Some(alg.to_string());
        }
    }
    None
}

这里的工程考量在于:服务端应该维护一个明确的白名单集合,默认禁用已知弱算法(如 diffie-hellman-group1-sha1、aes128-cbc、hmac-md5 等)。生产环境中需要提供运行时配置,允许安全团队动态调整算法策略。

三、密钥交换:Curve25519 实战

我们选择 curve25519-sha256 作为默认密钥交换算法,它在性能和安全性上都优于传统的 Diffie-Hellman group exchange。整个密钥交换流程如下:


                                              服务端
                                               │
  客户端                                        │ 生成随机数 r_s
   │                                            │ 计算 Q_s = r_s · G
   │                                            │
   │   ←──── SSH_MSG_KEX_ECDH_INIT (Q_s) ──────┤
   │                                            │
  生成随机数 r_c                                │
  计算 Q_c = r_c · G                           │
  计算 K = r_c · Q_s                           │
  计算 H = hash(V_C‖V_S‖I_C‖I_S‖K_S‖Q_c‖Q_s‖K)│
   │                                            │
   │  ───── SSH_MSG_KEX_ECDH_REPLY (K_S,Q_s) ──→│
   │                                            │
   │                               计算 K = r_s · Q_c
   │                               计算 H = hash(...)
   │                               签名 H → Sig_S(H)
   │   ←──── NEWKEYS ──────────────────────────│
   │                                            │
   ├────────── NEWKEYS ────────────────────────→│

核心计算在 Rust 中极为简洁,借助 x25519-dalek 库只需数行代码:


use x25519_dalek::{EphemeralSecret, PublicKey};
use sha2::{Sha256, Digest};

fn perform_kex(server_public_key: &PublicKey) -> (PublicKey, [u8; 32], Vec<u8>) {
    // 1. 客户端生成临时密钥对
    let client_secret = EphemeralSecret::random_from_rng(OsRng);
    let client_public = PublicKey::from(&client_secret);
    
    // 2. 计算共享密钥 K
    let shared_secret = client_secret.diffie_hellman(server_public_key);
    
    // 3. 构建交换哈希 H(用于服务端认证的签名验证)
    // H = HASH(V_C || V_S || I_C || I_S || K_S || Q_C || Q_S || K)
    let mut hasher = Sha256::new();
    hasher.update(b"SSH-2.0-my_ssh_server_0.1");   // V_C (客户端版本)
    hasher.update(b"SSH-2.0-OpenSSH_9.6");        // V_S (服务端版本)
    hasher.update(&kexinit_client_payload);        // I_C (客户端 KEXINIT)
    hasher.update(&kexinit_server_payload);        // I_S (服务端 KEXINIT)
    hasher.update(server_public_key.as_bytes());   // K_S (服务端主机公钥)
    hasher.update(client_public.as_bytes());       // Q_C (客户端临时公钥)
    hasher.update(server_public_key.as_bytes());   // Q_S (服务端临时公钥)
    hasher.update(shared_secret.as_bytes());       // K  (共享密钥)
    let exchange_hash = hasher.finalize().to_vec();
    
    (client_public, shared_secret.to_bytes(), exchange_hash)
}

四、会话密钥派生

密钥交换完成后,双方通过 NEWKEYS 消息同步进入加密通信阶段。SSH 从共享密钥 K 和交换哈希 H 派生出 6 个独立的会话密钥,采用类似 HKDF 的扩展方式:


K_1 = HASH(K || H || "A" || session_id)   // 初始 IV,客户端→服务端
K_2 = HASH(K || H || "B" || session_id)   // 初始 IV,服务端→客户端
K_3 = HASH(K || H || "C" || session_id)   // 加密密钥,客户端→服务端
K_4 = HASH(K || H || "D" || session_id)   // 加密密钥,服务端→客户端
K_5 = HASH(K || H || "E" || session_id)   // MAC 密钥,客户端→服务端
K_6 = HASH(K || H || "F" || session_id)   // MAC 密钥,服务端→客户端

注意 session_id 就是第一次密钥交换产生的 H 值,后续重协商(rekey)不会改变 session_id,但会重新派生所有会话密钥。重协商是 SSH 的重要安全特性:每传输 1GB 数据或每隔 1 小时,双方应重新执行密钥交换,实现前向保密——即使当前会话密钥泄露,也无法解密历史通信。

Rust 密钥派生实现:


fn derive_keys(
    shared_secret: &[u8],
    exchange_hash: &[u8],
    session_id: &[u8],
    cipher_key_len: usize,
) -> SessionKeys {
    let hash_fn = Sha256::digest;  // SHA-256,对应当前 KEX 算法
    
    let mut derive = |tag: u8| -> Vec<u8> {
        let mut key_material = Vec::new();
        key_material.extend_from_slice(shared_secret);
        key_material.extend_from_slice(exchange_hash);
        key_material.push(tag);
        key_material.extend_from_slice(session_id);
        let mut key = hash_fn(&key_material).to_vec();
        // 如果密钥不够长,继续扩展:K_{n+1} = HASH(K || H || K_1 || K_2 || ...)
        while key.len() < cipher_key_len {
            let mut next_input = Vec::new();
            next_input.extend_from_slice(shared_secret);
            next_input.extend_from_slice(exchange_hash);
            next_input.extend_from_slice(&key);
            key.extend_from_slice(&hash_fn(&next_input));
        }
        key.truncate(cipher_key_len);
        key
    };
    
    SessionKeys {
        iv_c2s: derive(b'A'),
        iv_s2c: derive(b'B'),
        enc_c2s: derive(b'C'),
        enc_s2c: derive(b'D'),
        mac_c2s: derive(b'E'),
        mac_s2c: derive(b'F'),
    }
}

五、服务端主机密钥与身份认证

SSH 通过服务端主机密钥(通常是 Ed25519 或 RSA-2048 密钥对)验证服务端身份,防止中间人攻击。密钥交换完成后,服务端必须用主机私钥对交换哈希 H 进行签名并发送给客户端。客户端拿到签名后,需要对照已知的主机密钥列表(~/.ssh/known_hosts)进行验证——这是 SSH 信任模型的核心。

Ed25519 签名验证流程:


use ed25519_dalek::{Verifier, PublicKey as EdPublicKey, Signature};

fn verify_host_key(
    public_key: &EdPublicKey,
    exchange_hash: &[u8],
    signature: &Signature,
) -> Result<(), SshError> {
    public_key
        .verify(exchange_hash, signature)
        .map_err(|e| SshError::HostKeyVerificationFailed(e.to_string()))
}

工程实践中,主机密钥应持久化存储在磁盘上(如 /etc/ssh/ssh_host_ed25519_key),并在服务启动时加载。对于容器化部署,需要考虑密钥的注入方式——不建议将密钥硬编码在镜像中,而应通过 Kubernetes Secret 或环境变量注入。

六、用户认证:公钥认证深度实现

完成传输层后,客户端通过 SSH_MSG_USERAUTH_REQUEST 发起认证。我们重点实现最常用的 publickey 认证方式:


客户端发送:
  byte      SSH_MSG_USERAUTH_REQUEST (50)
  string    user name
  string    service name ("ssh-connection")
  string    "publickey"
  boolean   TRUE(表示包含签名)
  string    public key algorithm name
  string    public key blob
  string    signature
       └─ 签名内容: session_id || SSH_MSG_USERAUTH_REQUEST || user || service || "publickey" ||
                   TRUE || algorithm || public key blob

服务端验证逻辑:

  1. 从授权列表(如 authorized_keys)中查找匹配的公钥
  2. 验证签名(证明客户端持有对应私钥)
  3. 验证通过则返回 SSH_MSG_USERAUTH_SUCCESS
  4. 
    async fn handle_publickey_auth(
        &self,
        username: &str,
        algorithm: &str,
        public_key_blob: &[u8],
        signature: &[u8],
        session_id: &[u8],
    ) -> Result<AuthResult, SshError> {
        // 1. 加载授权公钥列表
        let authorized_keys = self.load_authorized_keys(username).await?;
        
        // 2. 查找匹配公钥(比较公钥指纹)
        let matched_key = authorized_keys.iter()
            .find(|k| k.public_key_blob() == public_key_blob);
        
        let pubkey = matched_key.ok_or(AuthResult::Failure)?;
        
        // 3. 验证签名
        let signed_data = build_signed_data(
            session_id, username, "ssh-connection",
            algorithm, public_key_blob
        );
        pubkey.verify(&signed_data, signature)
            .map_err(|_| AuthResult::Failure)?;
        
        Ok(AuthResult::Success { username: username.to_string() })
    }
    

    一工程细节是"probe-only"认证:客户端可以先发送 boolean=FALSE 的认证请求,仅检查服务端是否接受该公钥方式,不实际签名。客户端通过这个机制探测可用的认证方法类型。

    七、连接层:通道复用机制

    连接层是 SSH 协议最复杂的部分。安全通道建立后,客户端可以打开多个逻辑通道(channel),每个通道承载一种会话类型。SSH 通过序列化数据流的方式在单一连接上实现多路复用——类似 HTTP/2 的流复用机制,但设计早了近十年。

    三种核心通道类型:

    通道类型 用途
    session 交互式 shell 或单条命令执行
    direct-tcpip 客户端→服务端端口转发(本地转发)
    forwarded-tcpip 服务端→客户端端口转发(远程转发)

    通道相关消息:

    
    channel 生命周期:
      open → request(pty/exec/shell/subsystem) → data(双向) → eof/close → closed
    

    通道复用的实现要点:

    • 每个通道有独立的窗口流控机制,发送方必须在窗口耗尽前暂停传输
    • 窗口大小约 2MB,消耗到一半时会收到 SSH_MSG_CHANNEL_WINDOW_ADJUST
    • 通道 ID 在客户端和服务端各自独立编号,通信时需要映射
    
    struct Channel {
        local_id: u32,
        remote_id: u32,
        channel_type: ChannelType,
        window_size: u32,          // 本地接收窗口
        max_packet_size: u32,      // 本通道最大包大小
        state: ChannelState,
        input_tx: mpsc::Sender<Vec<u8>>,
        output_rx: mpsc::Receiver<Vec<u8>>,
    }
    
    impl Channel {
        /// 发送数据,受窗口限制
        async fn send_data(&mut self, data: Vec<u8>) -> Result<(), SshError> {
            if data.len() as u32 > self.window_size {
                return Err(SshError::WindowExceeded);
            }
            self.window_size -= data.len() as u32;
            send_channel_message(self.remote_id, data).await?;
            Ok(())
        }
        
        /// 处理窗口调整(对方提供的额外窗口空间)
        fn handle_window_adjust(&mut self, increment: u32) {
            self.window_size = self.window_size.saturating_add(increment);
            // 唤醒可能被阻塞的发送者
        }
    }
    

    八、PTY 分配与 Shell 执行

    客户端打开 session 通道后,通常首先请求 PTY 分配(pty-req),然后执行 shell 或单条命令(shell 或 exec)。PTY 分配的参数包括终端类型(TERM)、行列尺寸、终端模式等。

    
    struct PtyRequest {
        term: String,             // 终端类型,如 "xterm-256color"
        width_chars: u32,         // 列数
        height_rows: u32,         // 行数
        width_pixels: u32,        // 像素宽
        height_pixels: u32,       // 像素高
        modes: Vec<(u8, u32)>,    // 终端模式 (opcode, value)
    }
    

    服务端收到 pty-req 后需要:

    1. 调用 forkpty()(POSIX)或手动分配 PTY 设备
    2. 设置 POSIX termios 参数(回显、行规程、控制字符等)
    3. 绑定 stdin/stdout/stderr 到 PTY 的从设备
    4. 通知窗口大小变更事件(SSH_MSG_CHANNEL_REQUEST + window-change)
    5. 在 Rust 中使用 nix crate 进行 PTY 操作:

      
      use nix::pty::{openpty, OpenptyResult, Winsize};
      use nix::unistd::{fork, ForkResult, setsid, dup2};
      
      fn setup_shell_pty(req: &PtyRequest) -> Result<(), Box<dyn std::error::Error>> {
          // 构造窗口大小
          let winsize = Winsize {
              ws_row: req.height_rows as u16,
              ws_col: req.width_chars as u16,
              ws_xpixel: req.width_pixels as u16,
              ws_ypixel: req.height_pixels as u16,
          };
          
          // 打开伪终端
          let OpenptyResult { master, slave } = openpty(&winsize, None)?;
          
          match unsafe { fork() }? {
              ForkResult::Child => {
                  // 子进程:成为会话首进程并执行 shell
                  setsid()?;
                  grantpt(&master)?;
                  unlockpt(&master)?;
                  let slave_fd = slave.as_raw_fd();
                  dup2(slave_fd, 0)?;  // stdin
                  dup2(slave_fd, 1)?;  // stdout
                  dup2(slave_fd, 2)?;  // stderr
                  std::process::Command::new("/bin/bash").status()?;
                  std::process::exit(0);
              }
              ForkResult::Parent { child: _ } => {
                  // 父进程:通过 master_fd 读写 PTY
              }
          }
          Ok(())
      }
      

      九、生产级实现的关键考量

      1. 抗 DoS:握手限速与资源配额

      SSH 是 DoS 攻击的常见目标。传输层握手涉及昂贵密码学计算(Ed25519 签名验证约 0.1ms/次,DH 密钥交换约 0.5ms/次),攻击者可以发起大量未完成握手的连接。

      防御策略:

      • 限制并发未完成握手连接数(如 50 个/IP)
      • 未认证连接限速(MaxStartups 10:30:60 – OpenSSH 风格)
      • 超时未完成的连接必须主动断开(30 秒内未完成握手)
      • 使用连接池复用底层 TCP 连接

      2. 审计日志:会话录制

      生产 SSH 服务器必须支持会话审计。两种常见方案:

      • 终端录制:在 PTY master 端记录所有 I/O(TIOCTTYLOG 模式),禁止客户端清除
      • 会话回放:记录输入输出带时间戳,生成可回放的 asciicast 格式

      3. 安全加固清单

      
      ✅ 仅允许 SSH Protocol 2(Protocol 1 已被证明不安全)
      ✅ 禁用 password 认证,仅允许 publickey(或配合 OIDC/证书认证)
      ✅ 禁用 root 直接登录,使用 sudo 提权
      ✅ 主机密钥使用 Ed25519(速度快、密钥短)
      ✅ 加密算法优先 aes256-gcm / chacha20-poly1305(AEAD 模式)
      ✅ diffie-hellman-group1/14-sha1 已废弃,改用 ecdh-sha2 或 curve25519
      ✅ 启用 ssh-audit 定期扫描弱算法
      ✅ fail2ban 或类似工具防御暴力破解
      

      4. 命名空间隔离:chroot 与容器

      高安全环境中,SSH 会话不应直接暴露宿主机的 shell 文件系统。可以采用:

      • chroot jail:将会话限制在特定目录树
      • Linux namespaces:独立的 PID、mount、network 命名空间
      • systemd-nspawn:轻量级容器,提供完整的文件系统隔离
      • gVisor (ptrace):用户态内核实现的额外安全边界

      十、Rust SSH 开源生态概览

      从零编写完整 SSH 服务器涉及数万行密码学和协议代码,实际项目中应优先使用成熟库:

      库/项目 用途 成熟度
      russh Rust SSH 服务器/客户端 活跃维护,API 简洁
      ssh2 (rust-ssh2) 基于 libssh2 的绑定层 功能全面,C 依赖
      thrussh (已归档) 纯 Rust 实现 不再维护
      openssh crate 通过 OpenSSH 配置/密钥交互 实用工具类

      russh 是当前 Rust 生态中最活跃的纯 Rust SSH 实现,支持 Curve25519、Ed25519、AES-256-GCM 等现代算法,适合直接用于生产。

      总结

      SSH 协议的分层设计是工程上的典范:传输层负责"如何安全通信",认证层负责"如何验证身份",连接层负责"如何复用会话"。通过本文的 Rust 实现实战,我们看到即便在现代密码学库的帮助下,正确实现 SSH 协议仍需要对协议细节有深入理解——从版本协商的边界条件到通道窗口流控的并发安全,每一步都暗藏工程陷阱。理解这些底层机制,不仅让我们能构建自己的 SSH 服务器,更能让我们在调试连接故障、优化传输性能、加固安全策略时心中有数。

      源码参考:本文代码片段基于 MIT 许可开源,完整实现可参考 GitHub 仓库,支持 Ed25519 主机密钥、Curve25519 密钥交换、AES-256-GCM 加密和公钥认证。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部