$ cat swift-nio-ssh-rsa-support.md
为 swift-nio-ssh 添加 RSA 支持
LogRaker 通过 swift-nio-ssh 进行 SSH 通信,这个库很好,但有一个缺口:它不支持 RSA。这是有意为之。上游只支持现代原语,对于新部署来说,较新的 ed25519 格式是更好的默认选择。
这也并不总是你能做的选择。Amazon EC2 发放的是 PKCS#1 RSA .pem 密钥对,直到 2021 年才增加 ed25519 密钥对。比那更早的任何密钥对都是 RSA,它打开的实例仍在运行,而从客户端这边,这种情况没有任何商量余地。一个无法使用放在 ~/.ssh/ 里的密钥的日志查看器没有用。
所以 LogRaker 引入了一个 fork。以下是实际需要做的事。
没有扩展点
显而易见的做法是从模块外部添加密钥类型。嗯,你做不到。NIOSSHPrivateKey 把它的密钥存储在一个 internal 枚举里:
extension NIOSSHPrivateKey {
internal enum BackingKey {
case ed25519(Curve25519.Signing.PrivateKey)
case ecdsaP256(P256.Signing.PrivateKey)
// ...
}
}
backingKey 是 internal,逐成员初始化器 init(backingKey:) 是 private,而唯一的 public 初始化器是每个受支持算法各一个。没有可遵循的协议,也没有可添加的 case。添加一个算法意味着修改模块,也就意味着一个新的 fork。
最终是八个文件:Package.swift 用来从 swift-crypto 的 CryptoExtras 产品引入 _RSA.Signing(它不在 Crypto 里),Sources/NIOSSH/ 下的五个源文件,以及一个必须修改的上游测试,因为它把 ssh-rsa 当作无人识别的算法示例。
陷阱:两个不同的字符串
这是即使你从不接触 nio-ssh 也值得读的部分。
一个 publickey 用户认证请求携带一个公钥 blob 和一个算法名。对于 ed25519 和 ECDSA 曲线,这两个字符串是相同的,所以上游把密钥自身的标签写进算法名字段,从不需要考虑这件事:
writtenBytes += self.writeSSHString(key.keyPrefix)
RSA 是它们会不同的那一个算法。密钥 blob 仍然标记为 ssh-rsa,但算法名必须指明签名算法:来自 RFC 8332 的 rsa-sha2-256 或 rsa-sha2-512。裸的 ssh-rsa 名称意味着使用 SHA-1 的 RSA,OpenSSH 8.8 在 2021 年默认禁用了它。把那个名称提供给任何当前服务器,它都会被直接拒绝,无论你的密钥有多好。
所以这个 fork 在 keyPrefix 旁边添加了 signatureAlgorithmName:
internal var signatureAlgorithmName: String.UTF8View {
switch self.backingKey {
case .rsa:
return Self.rsaSHA512SignatureAlgorithm
case .ed25519, .ecdsaP256, .ecdsaP384, .ecdsaP521, .certified:
return self.keyPrefix
}
}
微妙之处不在于这个属性。而在于算法名会被写入两个地方,并且它们必须逐字节一致:
- SSHMessages.swift,写入线路消息。
- UserAuthSignablePayload.swift,写入被签名的缓冲区。
一个是你在声称的,另一个是你在签名的。如果它们分叉,你发送的每个字节单独看都是格式良好的,密钥是正确的,签名是有效的 RSA 签名,但服务器仍然会拒绝它。没有错误消息指向这个不匹配,因为从服务器这边看,它和错误的密钥无法区分。这就是那种通过写出两个缓冲区并对比它们来发现的 bug。
现在有一个测试把两者钉在一起,所以下一个接触任一写入点的人会得到失败,而不是一个谜团。
提供哪个名称
这个 fork 总是提供 rsa-sha2-512,不进行协商。这听起来很偷懒,实际上是被迫的:nio-ssh 不解析 SSH_MSG_EXT_INFO,所以 server-sig-algs 列表永远不会到达任何能对它采取行动的代码。没有什么可用来协商的。
无论如何,这是一个安全的默认值。自 2016 年的 OpenSSH 7.2 起,rsa-sha2-512 就被接受了。日志查看器现实上会连接的任何东西都比那更新。
还有一个容易忽略的相关细节:这个 fork 的已知算法列表必须同时包含密钥标签和两个签名算法名称,因为该列表会与传入的 userauth 请求和 PK_OK 回复中的 algorithm-name 字段进行比对。一个密钥标签,对应多个合法名称。
让 fork 保持诚实
一个内嵌的 fork 会悄悄腐坏。有三件事让这个 fork 保持可读:
每一处修改都有标记。每个 hunk 都带有 LOGRAKER FORK: 注释,因此要查看相对上游的完整 diff,只需一次 grep:
grep -rn "LOGRAKER FORK" Sources/ Tests/ Package.swift
这同时也是 rebase 检查清单:拉取上游,rebase 到新 tag 上,这些标记会告诉你哪些冲突是你造成的。
有一个测试是关键的支撑。序列化测试会把生成的公钥 blob 与 ssh-keygen -y 对同一密钥打印出的结果逐字节比较。手写的 SSH 线格式恰恰是那种看起来合理的错误能通过评审的地方,而 OpenSSH 是唯一算数的意见。
安全公告不会主动找上你。这才是 vendoring 的真正代价,而且与 RSA 毫无关系。本地 SwiftPM 包永远不会报告新版本,所以没有任何工具会告诉你上游发布了修复。你必须自己去看:
gh api repos/apple/swift-nio-ssh/security-advisories \
--jq '.[] | "\(.severity) \(.ghsa_id) \(.summary)"'
把它放在你真正会去运行的地方。
你应该这样做吗?
如果有选择,大概不应该。fork 一个与安全相关的库,意味着你要永远负责它的上游修复,而对这次 fork 的诚实总结是:“我们加了一个作者们选择不加入的算法”。
但约束是真实存在的:这些密钥已经存在,它们是 RSA,而且它们能打开人们需要监控的机器。这个 fork 相对上游 0.15.0 改动了八个文件,每一处修改都有标记,等到 nio-ssh 自己支持 RSA 的那天,它就可以被删除。
这个 fork 是公开的,位于 lograker/swift-nio-ssh,和上游一样采用 Apache 2.0。依赖它之前请先读它的 README:它存在是为了服务一个应用,它被固定在 0.15.0,而且没有人审计过它。