
LogRaker 实现无需代理直接在 Secure Enclave 中使用 SSH 密钥
来源标题:SSH Keys in the Secure Enclave, Without an Agent
开发者在其应用 LogRaker 中实现了不依赖 ssh-agent 直接使用 Mac Secure Enclave 中的 SSH 密钥。由于 LogRaker 通过 swift-nio-ssh 自行实现 SSH 协议、不调用 OpenSSH,可将 Secure Enclave 密钥直接交给 SSH 库签名。作者称实现过程总体顺利,但也遇到几处初次使用时容易踩的坑。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
$ cat secure-enclave-ssh-keys-without-an-agent.mdgrep '^#' secure-enclave-ssh-keys-without-an-agent.md 存在于 Mac Secure Enclave 中的密钥根本无法被复制——这是铁律。私钥部分在一个独立的小型处理器内部生成,此后永远不会离开那里:不会交给应用,不会交给 macOS,甚至也不会交给你(而这正是关键所在)。这使得它几乎是存放 SSH 密钥的最佳场所,同时也是实际使用起来最麻烦的地方,因为 OpenSSH 需要先读取密钥,然后才能用它签名……而“读取”恰恰是 Secure Enclave 绝不允任何人做的事情。
所以大多数人选择了 agent 方案。像 Secretive 这样的工具把密钥保存在 Secure Enclave 中,并运行一个 ssh-agent,由 OpenSSH 向它请求签名;如果你整天泡在终端里,这套方案非常好用。
不过,我的应用 LogRaker 根本不运行 OpenSSH。它在应用内部通过 swift-nio-ssh 自己实现 SSH 协议,所以从一开始就不需要 agent,可以直接把 Secure Enclave 密钥交给 SSH 库。实现过程大体上很顺利,只是有几个坑在第一次尝试时让我吃了苦头(下文会详细说明)。如果你只想知道 LogRaker 把密钥存放在哪里、以及如何设置一个密钥,可以参考《Where LogRaker Keeps Your SSH Keys》。
var error: Unmanaged<CFError>? let access = SecAccessControlCreateWithFlags( nil, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, .privateKeyUsage, &error)! let key = try SecureEnclave.P256.Signing.PrivateKey(accessControl: access) 但你拿到的并不是一个可以随意保存到别处的密钥。key.dataRepresentation 是一个不透明的数据块,只有这台特定 Mac 的 Secure Enclave 才能把它还原成可用的密钥,在其他任何机器上它都只是一堆无用的字节。
这个数据块仍然需要妥善保管,因为在这台 Mac 上,它是密钥存在的唯一形式。一旦丢失,密钥就永久消失了,因为 Secure Enclave 并不保存它所创建密钥的列表。所以 LogRaker 把它保存在本机的钥匙串中,绝不同步,也绝不备份到其他任何地方;之后重新加载只需一行代码:
let key = try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: handle) 我原以为会很困难的部分
为 SSH 登录签名是我一直担心的部分,结果却发现它是最简单的,因为 swift-nio-ssh 已经可以直接接受 Secure Enclave 密钥:
let sshKey = NIOSSHPrivateKey(secureEnclaveP256Key: key) let openSSHLine = String(openSSHPublicKey: sshKey.publicKey) // "ecdsa-sha2-nistp256 AAAAE2VjZHNh..." 该库每次签名都会向 Secure Enclave 发出请求,而服务器看到的只是一个普普通通的 ecdsa-sha2-nistp256 密钥。CryptoKit 的 Secure Enclave 密钥仅支持 P-256,所以你能得到的只有这一种密钥类型;但 OpenSSH 从 2011 年起就接受它,dropbear(许多小型设备上的微型 SSH 服务器)也同样支持。当然,我在自己的船上把两者都试了一遍(NVIDIA Jetson 上的 OpenSSH,Victron Cerbo GX 上的 dropbear),两者都完全没问题。
然后公钥行就像任何其他密钥一样被写入服务器的 authorized_keys。LogRaker 的做法与 ssh-copy-id 相同:先用当前可用的方式登录,然后用新密钥再次连接,确认一切正常后才算完成任务。
第一个坑是 Touch ID,或者更准确地说,是每次签名都不需要 Touch ID。上面的访问控制只有 .privateKeyUsage,没有其他限制,所以密钥签名时不会弹出任何提示,这是有意为之。LogRaker 会自行重连日志流,如果服务器在凌晨 3 点重启,它就会自动重连;如果每次签名都需要指纹确认,这些连接就会一直挂起,直到我醒来。如果你确实想要提示,所有已存储的凭据前面有一道统一的关卡,一次 Touch ID 就能让你在 Mac 锁屏或休眠之前一直畅通无阻。
然后是同步问题。无法离开 Mac 的密钥也无法同步,但 LogRaker 的其他配置都通过 iCloud 同步。所以同步的是“选择”(即“使用 Secure Enclave 密钥登录”),而每台 Mac 各自维护一份从 Source 到本地密钥的映射。第二台 Mac 看到这个设置后,会发现自己还没有对应的密钥,于是会告知你,而不是抛出一个晦涩的认证错误。
还有一个吓人的坑:永远不要因为某些东西看起来缺失了就删除密钥。密钥列表通过 iCloud 同步,而 handle 保存在每台 Mac 各自的钥匙串中,因此很容易让人想去清理那些密钥已从列表中消失的 handle。但列表可能因为完全无害的原因而暂时不完整(iCloud 尚未同步,或者有人恢复了旧配置),而删除 handle 会永久销毁密钥。所以只有当真正删除那个密钥之后,handle 才会被移除,而且只在应用退出时才执行,这样在此之前“撤销”操作还能把它恢复回来。
我的第一个版本是先创建密钥、把公钥行放到服务器上,然后才保存 handle。如果保存失败,服务器就信任了一个已不存在的密钥,而且没有任何记录可以用来移除它。
现在的做法是先保存 handle,如果部署被取消或失败,再把它丢弃。
还有就是完全没有 Secure Enclave 的 Mac,比如一些仍在运行 macOS 15 的旧款 Intel Mac。在这些机器上,编辑器会悄悄回退到“私钥”模式,而保存 Source(哪怕只是重命名)就会把这个选择同步到所有其他 Mac 上,破坏它们的登录!现在这类 Mac 会保留该设置,并解释为什么无法使用它。
最后还有大小写问题。当你从服务器上移除一个密钥时,使用过它的 Source 会切换回之前的登录方式。我的查找逻辑对主机名进行精确比较,所以一个保存为 JETSON.LAN 的 Source 无法匹配部署到 jetson.lan 的密钥,那个 Source 就会一直尝试使用服务器已不再接受的密钥。主机名本就不区分大小写,现在查找逻辑也不区分了。
(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。