
最近在做一个基于 Tauri + Rust 的 SSH 终端客户端(OxideTerm),底层 SSH 协议用的 russh 。这周修了一个比较有意思的兼容性 bug ,涉及 RSA SHA-2 认证在严格 OpenSSH 配置下的三条路径,记录一下排查过程。
有用户反馈 RSA 私钥在某些新部署的服务端上登不上。让 AI 排查后发现不是单点故障,而是 RSA SHA-2 相关的三条认证路径都有问题:
根源是同一件事:OpenSSH 8.2+ 默认禁用 ssh-rsa ( SHA-1 ),只允许 rsa-sha2-256 / rsa-sha2-512。客户端如果还在用旧的算法名或者协商逻辑有漏洞,严格服务端直接拒。
私钥路径:没有根据 server-sig-algs 做正确的 RSA 算法协商。修复后按 sha2-512 → sha2-256 → ssh-rsa 的顺序尝试,服务端不让继续就停。
Agent 路径:调 russh 的认证 API 时 hash_alg 传了 None ,等于放弃了 SHA-2 协商。修复策略一样RSA key 走 SHA-2 顺序尝试,区分 transport error 和 sign error ,前者立即返回,后者换算法重试。
russh 0.59 暴露了 authenticate_certificate_with(cert, hash_alg, signer) 这个 API 。第一反应是把 hash_alg 传进去就行了?
不行。
hash_alg 确实能控制 signer 用哪个 hash 算法签名,但 russh 在把证书认证请求编码发给服务端的时候,外层的算法名仍然写死了 [email protected]。
真实 OpenSSH 的行为是:先看外层算法名,不对直接拒,根本不看你签名用了什么 hash。
客户端: "我要用 [email protected] 认证" 服务端: "这个算法我不认,拒绝。" 客户端: "但是我里面用的是 SHA-256 啊!" 服务端: "我不管里面是什么,外面名字不对就不行。" 正确的算法名应该是 [email protected] 或 [email protected]。
确认外层算法名是问题后,vendor 了一份 russh 0.59 做最小补丁只改证书发包路径的算法名编码。
改完跑真实 OpenSSH 测试,probe 阶段通过了。
但最终签名请求又挂了,OpenSSH 日志:
parse signature packet: unexpected bytes remain after decoding 不是算法名的问题了,是签名包格式有问题。
russh 的自定义 signer 协议要求返回值不是「裸签名」,而是「原始 to_sign buffer + 追加一个 length-prefixed signature blob 」。本地证书 signer 只返回了编码后的签名本体,少了前面的原始数据,OpenSSH 直接按格式错误处理。
对照 russh agent signer 的实现确认了这个协议约定后,修正了返回格式。
我没有让 AI 只写 mock test 就收工,因为我怕还是有问题,于是让 GPT 写了一组真实本地 OpenSSH 集成测试:
sshd + ssh-agentssh-keygen 生成真实 RSA key 和 user certificatersa-sha2-256-only 和 rsa-sha2-512-only 限制| 路径 | rsa-sha2-256-only | rsa-sha2-512-only |
|---|---|---|
| Agent | ||
| Certificate |
为什么坚持用真实 sshd ?因为这次的两层 bug算法名编码和签名包格式在 mock 环境下根本不会触发,只有在真实二进制流交换时才会暴露。
PubkeyAcceptedAlgorithms 的真实 OpenSSH 上才能复现[patch.crates-io] 引用本地 vendor ,上游修了随时切回如果你的服务端是这几年新部署的,或者手动加了:
PubkeyAcceptedAlgorithms rsa-sha2-256,rsa-sha2-512 那么任何基于 russh 0.59 做 SSH Certificate 认证的项目理论上都会踩到。不只是我们的问题,russh 本身在证书路径上的协议编码就有缺口。
对了,这是我们的项目,有兴趣的话也可以看看
项目地址: https://github.com/AnalyseDeCircuit/oxideterm
后续想法:打算把技术周记做成系列,手头积攒了不少坑。大家有兴趣的话我再发点?