让 agent 执行 sudo 的公钥认证:pam_ssh_agent_auth + 确认门

八月 11, 2026 [网络与安全] #sudo #安全 #SSH

需求背景

上一篇文章 用 sudo -A + askpass(pass + gpg-agent + yad)让 agent 执行 sudo 时弹窗授权:回车=允许、Esc=取消、免打字。这套方案解决了"未经同意静默 sudo"的问题,但有一个结构性残余风险:

任何人(或任何进程、任何 agent)只要绕过限制,执行 pass show sudo,就能拿到我的口令。

本文讨论这个风险到底有多真实、sudo 能否改用公钥体系认证(像 SSH 用 gpg-agent 里的带口令密钥认证那样),并最终给出一个可行的落地配置。

旧方案的残余风险

风险的真实边界

先纠正一个常见误解:gpg-agent 缓存的不是 sudo 密码,而是 GPG 私钥的 passphrase。sudo 密码以密文形式躺在 ~/.password-store/sudo.gpg 里,askpass 每次调用时临时解密一次,明文只短暂存在于 helper 的 stdout→sudo 管道中,不进上下文、不落盘。

但"泄露"的现实前提没那么难:

  1. 窗口期:gpg-agent 配置了 default-cache-ttl 86400(1 天),私钥解锁状态下,任何进程一条 pass show sudo 就静默解出明文,不弹任何提示。
  2. deny 规则只保护 opencode 一个 agent:~/.config/opencode/opencode.jsonc 里的 deny 规则管的是 opencode 自己的工具调用。换一个 agent(Claude Code、Cursor、任何有 shell 的脚本)规则完全不存在。
  3. 字符串匹配本身有缝:printf 'pass show sudo' | bash、gpg --output - --decrypt ~/.password-store/sudo.gpg 这类变体可能不命中。

所以"密码在磁盘上、且能被程序恢复出来"是真实存在的性质。

结构性事实:同一个用户 = 同一权限

pass show sudo 能拿明文,是因为 gpg-agent 里私钥已解锁,任何以你身份运行的进程都能静默解密。你没法造一个"我的合法 agent 能读、恶意进程不能读"的秘密——它们在同一个用户下权限相同。规则、守卫只能抬高门槛,抬不掉"明文可提取"这个性质。

两个威胁要分开

威胁对话框门防不防根子
agent 未经你同意静默 sudo✅ 防(Enter/Esc)这是门存在的意义
恶意进程偷走可复用密码❌ 不防密码本身可被提取

对话框门解决的是第一类;第二类唯一的解是让密码根本不存在。

sudo 有没有公钥认证?

没有。 sudo 的认证因子就是账户密码(PAM 密码认证),协议层没有像 SSH 那样的签名交换。要消除"账户密码"这个秘密,只能靠 PAM 模块把认证因子换成别的,主要是两条路:

方案口令是否可被程序恢复每命令人工门
pass + askpass(现状)是,磁盘上存在可解密的账户密码✅ 有(Enter/Esc)
pam_ssh_agent_auth否,agent 是签名器,密钥不可提取❌ 无
pam_u2f(FIDO2 硬件)否,密钥永不离 token✅ 有(物理按键)

关键区别在"无密码"那一列:SSH 那套的精华是把私钥放在 agent 里,agent 只做签名,密钥材料不可提取——哪怕拿到 agent 的全部字节也恢复不出私钥。而 pass 方案是"存密码、用密码",性质完全不同。这就是为什么最开始的直觉(gpg-agent + 带口令 SSH 密钥认证)方向是对的。

为什么 pam_ssh_agent_auth 难加人工门

你可能会想:那就在 pam_ssh_agent_auth 上叠一个人工确认门,每次 sudo 都弹个框。技术上可行,但门放哪才不被绕过才是症结:

真能在 PAM 层给出人工门的三种方式

方式门是什么能否绕过代价
gpg-agent default-cache-ttl-ssh 0每次签名弹 pinentry 重输密钥口令不能(agent 拒绝无口令签名)每次都要敲口令,且影响所有 SSH 签名(含 ssh 登录)
自写 PAM 模块 / pam_exec 弹窗弹 yad 确认框可被注入 Enter 伪造(软件"声称有人确认")对话框对"人的 sudo"也生效,要处理显示路由
pam_u2f(FIDO2 硬件)物理按键,由硬件验证不能需硬件,远程/waypipe 场景别扭

弹窗门 vs U2F:交互等价,性质不同

交互上两者都是"每次 sudo 要人做一个物理动作"。区别在谁能验证"这是人做的":

所以差异不在交互,而在**"人"这个字的验证方是软件还是硬件**。如果你的威胁模型是"机器上可能有恶意进程/被攻陷的 agent 会自己批准对话框",只有 U2F 这种硬件验证才算真门;如果只是"合法 agent 需要我每次确认",弹窗门够了。

社区怎么看 pam_ssh_agent_auth

最终方案:pam_ssh_agent_auth + pam_exec 确认门

结合需求与可行性,选定:PAM 栈里在 pam_ssh_agent_auth 前面叠一个 pam_exec 确认门。不写 C 模块、不换 agent(gpg-agent 继续当唯一的 ssh-agent),并用 SUDO_ASKPASS 环境变量区分 agent 调用和人工终端调用,人工的 sudo 保持原有密码体验。

架构

sudo(agent) ──> PAM
   ① pam_exec 确认门(仅当 SUDO_ASKPASS 已设置 → 弹 yad:Enter=放行/Esc=拒绝)
   ② pam_ssh_agent_auth(gpg-agent 里的 auth 子钥签名;密钥不可提取)
   ③ common-auth 密码回退(仅人工终端 sudo 走这里,密钥缺省时兜底)

密钥路径下 pass 里的 sudo 密码整个移除,磁盘上没有任何可恢复口令;账户密码只留在 /etc/shadow(哈希,不可恢复)。

具体配置

1. 安装模块

sudo apt install libpam-ssh-agent-auth

2. 登记授权公钥(用 gpg-agent 里现有的 auth 子钥)

ssh-add -L                        # 从 gpg-agent 列出 ssh 公钥
sudo mkdir -p /etc/pam_ssh_agent_auth.d
sudo install -m 644 <上面列出的公钥> /etc/pam_ssh_agent_auth.d/peter

3. /etc/pam.d/sudo(在 @include common-auth 前插入两行)

auth    required     pam_exec.so quiet /usr/local/libexec/sudo-confirm
auth    sufficient   pam_ssh_agent_auth.so file=/etc/pam_ssh_agent_auth.d/$USER
@include common-auth

4. /etc/sudoers.d/ssh-agent-auth(让这些环境变量进 PAM)

Defaults env_keep += "SSH_AUTH_SOCK SUDO_ASKPASS DISPLAY WAYLAND_DISPLAY XAUTHORITY XDG_RUNTIME_DIR"

5. 确认门脚本 /usr/local/libexec/sudo-confirm

#!/bin/sh
# 只有 agent 路径(SUDO_ASKPASS 已设置)才弹确认框;人工终端 sudo 直接放行
[ -n "$SUDO_ASKPASS" ] || exit 0

if /usr/bin/yad --title="sudo 授权请求 [$(hostname)]" \
       --text="确认授权本次 sudo 操作?" \
       --button="允许:0" --button="取消:1" --center 2>/dev/null; then
    exit 0
else
    exit 1
fi

6. 兜底:堵死 pass 泄漏路径

把 /usr/local/libexec/sudo-askpass 改成"永不输出密码"(密钥路径下 askpass 根本不会被调用,这里只是防止密钥缺失时兜底回退到 pass):

#!/bin/sh
exit 1

可选再加 pass rm sudo 删掉口令,双保险。

行为验证

场景行为
人工终端 sudo ...无弹窗,密码提示,5 分钟缓存照旧
agent SUDO_ASKPASS=... sudo -A -k ...每次弹确认框,Enter 放行 / Esc 拒绝
密钥未加载时 agent sudo确认框后 pam_ssh_agent_auth 失败→askpass 拒绝→sudo 失败(fail-closed)

落地前要实测的两个点

  1. yad 以 root 跑在用户显示上:pam_exec 默认以 root 运行,Wayland/X 的 socket root 一般能连,但要在机器上实测一次。
  2. file=...$USER 展开:若模块版本不展开变量,改成硬编码路径 /etc/pam_ssh_agent_auth.d/peter。

远程(waypipe)场景

远程机的 PAM 栈需要拿到本地的 agent:waypipe --no-gpu ssh -A -t server 'env SUDO_ASKPASS=... sudo -A cmd'——-A 做 agent 转发,远程签名请求回到本地 gpg-agent,确认框经转发的显示弹在本地屏幕。注意:agent 转发也意味着远程机能用你的密钥,按需权衡。

备选方案

总结