Chips

SSH 主机密钥漂移:声明了三个,只有一个存在

· 约 4 分钟 · 1491 字

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_keysshd 对不存在的 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 一条入口,那就是把自己锁在门外。

所以顺序必须是:

  1. 生成新密钥,但先不让 sshd 加载
  2. 把新指纹通过现有通道交付给客户端,让它们在 known_hosts 里预信任
  3. 确认全部客户端已更新后,再启用

生成在暂存目录,而不是 /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,回滚会让它们重新报警。这类改动没有"无声"的方向,两边都要通知。

小结

← 全部文章