让 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 管道中,不进上下文、不落盘。
但"泄露"的现实前提没那么难:
- 窗口期:gpg-agent 配置了
default-cache-ttl 86400(1 天),私钥解锁状态下,任何进程一条pass show sudo就静默解出明文,不弹任何提示。 - deny 规则只保护 opencode 一个 agent:
~/.config/opencode/opencode.jsonc里的 deny 规则管的是 opencode 自己的工具调用。换一个 agent(Claude Code、Cursor、任何有 shell 的脚本)规则完全不存在。 - 字符串匹配本身有缝:
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 都弹个框。技术上可行,但门放哪才不被绕过才是症结:
- 包一层 wrapper 无效:密钥已解锁时,
sudo直接走 PAM 认证成功,根本不会经过 wrapper;进程绕过 wrapper 直接执行sudo就行,门形同虚设。 - agent 是共享签名器,签名成功时没有提示点:任何进程都能请 agent 签名,agent 无从区分"人批准的"和"进程自发的"。sudo 又没有"按 y 确认"这种概念——它唯一的门就是认证提示。
- pam_ssh_agent_auth 内部没有"必须确认"选项:它只在 agent 无匹配密钥时才回退到密码对话(此时要的门是反向的)。
ssh-add -c的确认约束也只对 OpenSSH 的 ssh-agent 生效,gpg-agent 不实现。
真能在 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 要人做一个物理动作"。区别在谁能验证"这是人做的":
- 弹窗 Enter:恶意进程可以用
ydotool/wtype/xdotool注入按键,sudo 收到的只是"一次按键事件",分不清是人按的还是程序按的。门是软件声称有人确认了。 - U2F 触摸:FIDO2 的"用户在场"检查发生在硬件 token 内部,没按实体键 token 就拒绝签名,user-presence 位由硬件置位,软件注入任何事件都无效。
所以差异不在交互,而在**"人"这个字的验证方是软件还是硬件**。如果你的威胁模型是"机器上可能有恶意进程/被攻陷的 agent 会自己批准对话框",只有 U2F 这种硬件验证才算真门;如果只是"合法 agent 需要我每次确认",弹窗门够了。
社区怎么看 pam_ssh_agent_auth
- 本质是"密钥持有的免密 sudo",设计意图是"能用 SSH 密钥登录就有权 sudo"(自动化/Ansible 场景),不是"每命令确认"。多数安全向讨论把它归入与 NOPASSWD 同类,属便捷而非安全机制。
- 固有弱点:agent 是共享签名器,同一会话任何进程(包括被攻陷的模型)都能请它签名。
- 真正需要"每命令人工批准 + 无秘密落盘"的场景,社区的落地方案就是 askpass 对话框 + 密码保管库(pass/1Password/KeePass),或 FIDO2 硬件。
最终方案: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) |
落地前要实测的两个点
- yad 以 root 跑在用户显示上:
pam_exec默认以 root 运行,Wayland/X 的 socket root 一般能连,但要在机器上实测一次。 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 转发也意味着远程机能用你的密钥,按需权衡。
备选方案
- gpg-agent
default-cache-ttl-ssh 0:零新增文件,但每次 SSH 签名(包括 ssh 登录)都要重输口令,全局影响。 - pam_u2f(FIDO2 硬件):密钥永不离 token、物理按键由硬件验证,是"非绕过人工门"的终极答案;代价是需硬件,远程场景 token 要在目标机上或做 U2F 转发。
总结
- 旧 pass 方案的残余风险是真实的:口令在磁盘上、能被程序恢复,deny 规则只是 opencode 层的保护且可绕过;结构性原因是"同一用户 = 同一权限",无法造"自己能读、恶意进程不能读"的秘密。
- 两个威胁要分开:对话框门防"未同意静默 sudo",防不了"偷走可复用密码";后者只能靠无密码——签名器(pam_ssh_agent_auth)或硬件(pam_u2f)。
- sudo 没有原生公钥认证,只能靠 PAM 模块换认证因子。
- pam_ssh_agent_auth 加人工门技术上可行,但 agent 是共享签名器、无提示点,弹窗门可被注入按键伪造;要"非绕过门"只能靠 U2F 硬件。
- 最终方案:
pam_exec确认门 +pam_ssh_agent_auth,用SUDO_ASKPASS区分 agent 与人工路径,密钥不可提取、每次 agent 的 sudo 都弹确认框,并移除 pass 里的 sudo 口令。