SSH 主机密钥漂移:声明了三个,只有一个存在
sshd_config 里声明了 ed25519/ecdsa/rsa 三种 HostKey,但实际只有 RSA 密钥文件存在,导致大量老客户端在密钥交换阶段就失败。记录判断依据,以及分批启用新主机密钥时避免把自己锁在外面的做法。
现象
客户端连接失败,服务端日志里反复出现:
Unable to negotiate with 203.0.113.7 port 51234: no matching host key type found.
Their offer: ssh-rsa,ssh-dss
同时另一部分客户端完全正常。失败的都是较老的客户端。
原因:配置声明与实际文件不一致
sshd_config 里写了三行:
HostKey /etc/ssh/ssh_host_rsa_key
HostKey /etc/ssh/ssh_host_ecdsa_key
HostKey /etc/ssh/ssh_host_ed25519_key
但 /etc/ssh/ 下只有 ssh_host_rsa_key。sshd 对不存在的 HostKey 只是启动时记一条警告,不会拒绝启动,于是服务端实际只能提供 RSA 一种。
新客户端没问题:它们能协商 rsa-sha2-512。老客户端只支持 ssh-rsa(SHA-1 签名),在新版 OpenSSH 上已被默认禁用,于是在密钥交换阶段——还没进入认证——就失败了。
这类问题的定位成本主要在于症状指向客户端("你的客户端太老"),而根因在服务端的配置漂移。
正确的观察方式是比较两端:
# 服务端声称提供什么
sshd -T | grep -i '^hostkey '
# 实际存在什么
ls -l /etc/ssh/ssh_host_*
# 实际协商到了什么(客户端侧,加 -v)
ssh -v user@host 2>&1 | grep -i 'host key'
三者的差集就是答案。
修复:加密钥不难,难的是别把已有客户端锁在外面
直接生成缺的两个密钥再 reload,看起来是一步操作。但有个后果:
客户端的 known_hosts 里只记着 RSA 的指纹。 一旦服务端开始提供 ed25519,客户端会协商到 ed25519,指纹对不上。用 StrictHostKeyChecking=yes(或者之前接受过指纹、现在严格比对)的客户端会直接拒绝连接,报中间人攻击警告。
如果这台机器只有 SSH 一条入口,那就是把自己锁在门外。
所以顺序必须是:
- 生成新密钥,但先不让 sshd 加载
- 把新指纹通过现有通道交付给客户端,让它们在
known_hosts里预信任 - 确认全部客户端已更新后,再启用
生成在暂存目录,而不是 /etc/ssh/
mkdir -p /srv/ssh-staged && chmod 700 /srv/ssh-staged
ssh-keygen -t ed25519 -f /srv/ssh-staged/ssh_host_ed25519_key -N ''
ssh-keygen -t ecdsa -b 521 -f /srv/ssh-staged/ssh_host_ecdsa_key -N ''
chmod 600 /srv/ssh-staged/ssh_host_*
ssh-keygen -lf /srv/ssh-staged/ssh_host_ed25519_key.pub # 这一步就是要交付的指纹
这里的选择是有意的:只要私钥文件出现在 /etc/ssh/,任何一次意外的 sshd reload 都会让它静默生效。而 reload 并不罕见——openssh-server 的自动升级、配置管理工具、甚至一次手滑的 systemctl reload ssh,都会触发。
放在暂存目录(且 HostKey 不指向它),新密钥就不会被加载,交付指纹的时间窗才是安全的。
启用时先校验指纹
启用脚本在安装前重新计算指纹并与预期值比对:
#!/bin/bash
set -euo pipefail
expect='SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
actual=$(ssh-keygen -lf /srv/ssh-staged/ssh_host_ed25519_key.pub | awk '{print $2}')
[ "$actual" = "$expect" ] || { echo "指纹不符,中止"; exit 1; }
install -m 600 /srv/ssh-staged/ssh_host_ed25519_key /etc/ssh/
install -m 644 /srv/ssh-staged/ssh_host_ed25519_key.pub /etc/ssh/
sshd -t && systemctl reload ssh
这一步防的是暂存文件在等待期内被替换——比如目录权限写错,或者中途有人误操作。校验成本极低,而它防的后果(客户端收到未预期的指纹,或更糟:攻击者插入自己的主机密钥)代价很高。
启用完成后把暂存目录删掉。私钥副本越少越好,第三份副本存在的唯一作用是等待期,等待期结束它就成了负债。
为什么用 reload 而不是 restart
ssh.service 的 reload 走 ExecReload=kill -HUP $MAINPID。sshd 收到 SIGHUP 后是原地重新执行(re-exec),重新读取配置、重新绑定端口,而已经建立的连接由子进程继续持有,不受影响。
配合 KillMode=process,正在使用的 SSH 会话在 reload 后依然存活——这是唯一能在"只有一条 SSH 入口"的机器上安全改配置的方式。restart 则会杀掉全部子进程。
改之前先验配置:
sshd -t && systemctl reload ssh
sshd -t 失败时 reload 不会执行(reload 路径本身也包含一次配置测试),但仍值得先跑一次——把失败暴露在当前这条连接里,而不是在下一次连接时。
顺带清掉的惰性残留
/etc/ssh/ 里还躺着一个 DSA 主机密钥(1024 位)。DSA 在密码学上已经不可用,但它不在 HostKey 列表里,所以从来没有对外提供过——这是最典型的惰性残留:没有实际风险,但会让人误以为系统在用 DSA,也会出现在各种安全扫描报告里制造噪声。
判断一个残留是否是惰性的,标准就是看它是否在生效路径上:
sshd -T | grep -i hostkey # 生效路径
ls /etc/ssh/ssh_host_* # 磁盘上有什么
差集里的东西可以安全移除,但移除前要确认没有别的东西在引用它(比如 ssh-keyscan 的历史记录、监控系统的指纹基线)。
回滚
rm -f /etc/ssh/ssh_host_{ed25519,ecdsa}_key{,.pub} && sshd -t && systemctl reload ssh
回滚到单一的 RSA 主机密钥。注意回滚同样会引起指纹变化——如果客户端已经信任了 ed25519,回滚会让它们重新报警。这类改动没有"无声"的方向,两边都要通知。
小结
sshd -T的输出、磁盘上的密钥文件、客户端实际协商到的算法,三者要一起看- 新增主机密钥类型不会让旧密钥失效,但会让客户端协商到新指纹——
known_hosts没更新就会拒绝连接 - 新密钥先在暂存目录生成、交付指纹、校验后再启用;别让它一生成就落在
HostKey能加载的位置 - reload(SIGHUP 原地 re-exec)保留已有会话,是单入口机器上改 SSH 配置的唯一安全方式