Wankupi's Website

SSH Agent 配置记录

近期我忽然意识到了 ssh private key 应该加密保存,以防止恶意进程读取或者直接从硬盘中被读取。(虽然有些杞人忧天)

添加密码后,我很疑惑为什么我连接网站并没有提示我输入密码。打出调试信息发现是从 ssh-agent 获取了密钥。

我之前配置了 gpg-agent,并用它当作 ssh-agent。 发现 gpg-agent 是将密钥直接存储为文件,而我之前添加的时候,没有添加passphrase,所以连接 ssh 时仍然可以使用该密钥。

但曾经配置时只是懵懵懂懂,没理清其中机制,也有点忘了为什么之前我启用了 ssh-agent服务。 我索性直接删去了相关配置。

但很快我开始感到麻烦了。 开始发现每一次使用都需要输入密码;在远程服务器上,运行 git fetch/push 会缺少密钥。

于是我开始认真了解相关概念。

我的需求

  1. 密钥本身在磁盘上应当是加密的,保证无法直接从硬盘中读取到密钥(至少是口令加密的)。(可惜我没有硬件密钥设备,不然硬件设备将会是最安全的方法。)
  2. 不想每次连接 ssh 都输入密码。
  3. 必须启用 ssh-agent 类服务,因为我不想每台远程机器创建一个密钥,太麻烦了。
  4. 不想每次开机后还得自己执行一遍 ssh-add,我总是尝试连接 ssh 发现需要输入密码后,才想起来还没添加到缓存。
  5. [optional] 希望授权操作尽可能自动。

解决方案

因为一定要使用 ssh-agent 类服务,同时希望关机时保持缓存,仍然采用了 gpg-agent, 但在 ssh-add 时添加了 passphrase。

于是达成效果:每次使用 ssh key 时都会激活一个弹窗询问密码。 我更喜欢弹窗,而不是终端输入密码(某种仪式感),于是为 gpg-agent 配置了 pinentry-qt;如果使用 ssh-agent 也可以配置使用 ksshaskpass,但是使用 ssh-agent 需要开机手动加入一次,但我不想开机就冒出来一个输入密码弹窗...

本来我以为每次输入一遍密码也许是免不了了。 但忽然就想起来 KDE 上有个东西叫 KWallet,似乎就是管理密钥密码之类的统一工具。 印象中,Edge, Slack, Vscode 等基于 Chromium 内核的浏览器就会在这里存储一些短密钥。 打开 KWalletManager 一看,果然如此,还有 NetworkManager 也会在此存储用户的 wifi 密码等信息。

查阅文档发现,KWallet 提供了 D-Bus XDG Secret Service API,可以供应用安全存储密码。相当于桌面环境统一为用户维护了一套口令加密的密码本。 虽然说我之前装系统图方便,没有设置密码,所以所有密码信息最终都算是明文存储在硬盘。 但这里是可以设置密码的。

最关键的是,我发现 kWallet 有一个非常方便的能力,即当该密码本密码与用户登录密码相同时,可以自动解锁。

于是再去查阅 GnuPG 的文档和 KWallet 的文档,发现当配置 GPG 使用 pinentry-qt 时,是可以使用 Secret Service API 的,只不过为了避免循环依赖(密码本使用 gpg 加密,gpg向密码本询问密码),默认未开启。开启后密码输入弹窗会有一个是否的勾选框,询问是否将密码保存到用户密码本。

如果勾选,并输入正确的密码,那么密码将存入用户密码本,此后对该密钥的访问都会先尝试查询密码本,如果存在,则无感通过。

最终实现的效果为,只需要开机时,登录用户时输入一次密码,此后不需要再输入任何密码,同时保证了硬盘上不存在明文。

How ?

这些工具都是怎么发挥作用的呢?下面将我探索的内容予以记录。

Use local key in remote server

配置 ForwardAgent yes 后,ssh将根据环境变量 SSH_AUTH_SOCK 表示的路径,转发一个 socket,并对应设置远程的 SSH_AUTH_SOCK 环境变量为转发后的监听路径。 该 socket 由本地的 ssh-agent 监听,响应密钥的计算任务。

在远程服务器上运行 ssh 命令时,将通过该 socket 与本地 ssh-agent 通信,了解有哪些密钥,并在 pubkey challenge 阶段转发运算任务。 agent 协议只提供列出公钥、用指定密钥签名这类操作,私钥始终不离开本地。

软件如何访问密码本

进程 ksecretd 统一管理加密的密码本文件的访问,并通过 d-bus 提供标准的 secret service。

引用内容为 AI 生成。

一个密码本就是一个 collection,其中每条密码是一个 item,用一组自定义属性作检索键。 应用取密码的流程大致是:建立会话,按属性查询,密码本若锁着就请求解锁,最后读出。

解锁这步的设计挺巧,服务端不会阻塞着等用户输入,而是返回一个 prompt 对象,交给客户端去弹窗。 密码本已自动解锁时,服务端直接说不需要 prompt,于是全程无感。

应用一般也不自己写这些 d-bus 调用,用 libsecret、QtKeychain 之类的封装库即可。 pinentry 就是用 libsecret 把 passphrase 存成一条 item,以密钥的 keygrip 作检索键,此后每次先查一遍密码本,命中就不弹窗了。

至于上文说的默认未开启:pinentry 里有段特判,认出桌面是 KDE 就关掉这个功能,除非设了环境变量 PINENTRY_KDE_USE_WALLET。 正是为了防那个循环依赖。我的密码本是 Blowfish 加密的文件,没这个环,设上变量即可。

一次输入,同时登录和解锁

这就要提到 Linux 下现在的登录认证流程。登录时,会通过 PAM 进行密码验证、权限验证、用户 session 初始化等。

可以大致理解为在 session 初始化阶段加了一个”钩子“,该过程能看到用户输入的密码,然后该钩子会启动 ksecretd 并传递密钥。

引用内容为 AI 生成。

这个钩子是 kwallet-pam 提供的 pam_kwallet5.so,挂在 sddm 的 PAM 配置里。 传递的不是明文。 登录密码只暂存在 PAM 自己的内存里,真正交给 ksecretd 的是用 PBKDF2 派生出的密钥,也就是密码本文件的加密密钥,经一个匿名管道传过去,不落盘也不过 d-bus。

Future Work

密码本对请求密码的进程没有什么核验。无法防御登录后有恶意进程发起请求。

Reference

  1. KDE Wallet, ArchWiki, https://wiki.archlinux.org/title/KDE_Wallet
  2. GnuPG § pinentry, ArchWiki, https://wiki.archlinux.org/title/GnuPG#pinentry
  3. PAM, ArchWiki, https://wiki.archlinux.org/title/PAM
  4. Secret Service API, freedesktop.org Specifications, https://specifications.freedesktop.org/secret-service/latest/
  5. kwallet-pam 源码, KDE Invent, https://invent.kde.org/plasma/kwallet-pam
  6. SSH Agent Protocol, IETF Internet-Draft, https://datatracker.ietf.org/doc/draft-miller-ssh-agent/