从零构建 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
服务端验证逻辑:
- 从授权列表(如
authorized_keys)中查找匹配的公钥 - 验证签名(证明客户端持有对应私钥)
- 验证通过则返回
SSH_MSG_USERAUTH_SUCCESS - 每个通道有独立的窗口流控机制,发送方必须在窗口耗尽前暂停传输
- 窗口大小约 2MB,消耗到一半时会收到
SSH_MSG_CHANNEL_WINDOW_ADJUST - 通道 ID 在客户端和服务端各自独立编号,通信时需要映射
- 调用
forkpty()(POSIX)或手动分配 PTY 设备 - 设置 POSIX termios 参数(回显、行规程、控制字符等)
- 绑定 stdin/stdout/stderr 到 PTY 的从设备
- 通知窗口大小变更事件(
SSH_MSG_CHANNEL_REQUEST+window-change) - 限制并发未完成握手连接数(如 50 个/IP)
- 未认证连接限速(
MaxStartups 10:30:60– OpenSSH 风格) - 超时未完成的连接必须主动断开(30 秒内未完成握手)
- 使用连接池复用底层 TCP 连接
- 终端录制:在 PTY master 端记录所有 I/O(TIOCTTYLOG 模式),禁止客户端清除
- 会话回放:记录输入输出带时间戳,生成可回放的 asciicast 格式
- chroot jail:将会话限制在特定目录树
- Linux namespaces:独立的 PID、mount、network 命名空间
- systemd-nspawn:轻量级容器,提供完整的文件系统隔离
- gVisor (ptrace):用户态内核实现的额外安全边界
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
通道复用的实现要点:
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 后需要:
在 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/次),攻击者可以发起大量未完成握手的连接。
防御策略:
2. 审计日志:会话录制
生产 SSH 服务器必须支持会话审计。两种常见方案:
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 文件系统。可以采用:
十、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 加密和公钥认证。

发表评论 取消回复