GPG 使用指南
七月 18, 2025 更新: 八月 14, 2026 [网络与安全] #gpg #安全 #Git #SSH #密码学GPG 使用指南
基本操作
生成密钥
gpg --full-gen-key --expert
查看密钥
gpg -k # 公钥
gpg -K # 私钥
gpg --list-keys --with-keygrip
gpg --fingerprint <key-id>
导出/导入
# 导出公钥(文本格式)
gpg --armor --export peter > public.asc
# 导出私钥
gpg --armor --export-secret-key peter > private.asc
# 导入
gpg --import key.asc
导出/导入的单位是"完整身份":--export <任意id>(主密钥 id、子密钥 id)输出完全相同的包(实测 md5 一致)——主密钥公钥 + uid + 全部子密钥公钥 + 各证书。子密钥不能脱离身份单独存在;--export <主id>!(带 !)可只导出主密钥+uid(不含子密钥),但接收方会因缺 [E] 子密钥无法加密(实测加密失败)。
公钥服务器(Keyserver)
公钥服务器是公钥目录服务,只存公钥(绝无私钥),作用如"电话簿":别人按邮箱/指纹搜索下载你的公钥。
# 搜索并下载公钥
gpg --keyserver hkps://keys.openpgp.org --search-keys <UID或邮箱>
# 已知密钥 ID 直接接收
gpg --keyserver hkps://keys.openpgp.org --recv-keys <KEY_ID>
# 上传自己的公钥(上传后需邮箱验证才被收录)
gpg --keyserver hkps://keys.openpgp.org --send-keys <KEY_ID>
SKS 池 vs 现代单服务器:SKS 池是去中心化多节点同步(可匿名上传、易被垃圾密钥污染、难删除);keys.openpgp.org 等现代服务器是单中心(上传需邮件验证确认身份、支持删除、可靠性高)。现代实践不再依赖池。
keys.openpgp.org 的特殊规则(官方文档):
- 非身份信息(全部子密钥 + 元数据)→ 全部存储分发,只要通过完整性校验
- 身份信息(uid)→ 需邮箱验证同意后才发布(隐私保护)
所以 --send-keys <主id> 上传时所有子密钥都会跟着上传——keyserver 不提供"只保留指定子密钥"的裁剪功能;gpg 本地也没有(--export-filter drop-subkey 只支持按数值字段过滤,不支持按 keyid)。想精简只能生成密钥时就规划好子密钥数量,或对废弃子密钥用 --revoke 吊销。
Keyserver 只解决"找得到公钥",不解决"公钥是真的":从服务器下载的公钥必须独立渠道核对指纹(当面/电话/IM)后再 --sign-key 信任,信任链(Web of Trust)只针对主密钥,子密钥随身份被验证而非被信任。
签名操作
gpg --sign file.txt # 签名后内嵌原文
gpg --clearsign file.txt # 签名后原文可读
gpg --detach-sign file.txt # 分离签名,生成 .sig 文件
gpg --verify file.txt.asc # 验证签名
加密/解密
# 发送方使用接收方公钥加密
gpg -r <recipient> --encrypt file.txt
# 接收方使用自己的私钥解密
gpg --decrypt file.txt.asc > file.txt
同时签名和加密
gpg --local-user <sender> --recipient <receiver> --armor --sign --encrypt file.txt
信任网络
GPG 不依赖 PKI,而是通过"信任网"(Web of Trust)建立信任:
gpg --sign-key <key-id> # 用自己的密钥签名对方的公钥
gpg --check-sigs # 检查签名
主密钥 ≈ 个人"根 CA"
主密钥的 [C] 认证能力(Certify)本质就是 CA 式的签名能力:
| OpenPGP 概念 | CA 类比 |
|---|---|
主密钥 [C] 认证能力 | CA 的证书签名能力 |
| 对自身 UID 的签名(self-sig) | CA 给自己签发证书 |
gpg --sign-key 认证他人 | CA 签发被认证者的证书 |
子密钥([S]/[E]/[A]) | CA 签发的"用途受限"证书 |
| 密钥过期 / 吊销证书 | 证书有效期 / CRL 吊销 |
关键区别——信任模型:
- X.509:层级制,信任靠"根 CA → 中间 CA → 叶子证书"的信任链,单向、自上而下
- OpenPGP:去中心化信任网,没有固定根;任何主密钥都能给别人签发认证,自己是自己的根、同时也能当别人的"CA"——信任不靠链条继承,而靠"我认识的人签过他的钥匙"
所以每次 gpg --sign-key 都是在执行一次 CA 式的认证动作,只是没有全局权威——整个 GPG 生态是"人人都是 CA"的分布式体系。
子密钥管理
gpg --expert --edit-key peterxiao
gpg> addkey # 添加子密钥
gpg> key 2 # 选择子密钥
最佳实践:生成主密钥后,导出备份到物理存储设备,然后从日常设备中删除主密钥仅保留子密钥:
gpg -a --export-secret-keys > master-secret-key.gpg
gpg -a --export-secret-subkeys > sub-secret-keys.gpg
gpg --delete-secret-key <key-id>
gpg --import sub-secret-keys.gpg
此时 gpg --list-secret-keys 会显示 sec#(主密钥已删除)。
密钥结构:ed25519 主密钥 + cv25519 加密子密钥
现代 GnuPG 新密钥默认组合是主密钥 ed25519 + 加密子密钥 cv25519。两者都基于 Curve25519,是同一条曲线的两种数学形态(Montgomery ↔ twisted Edwards 双有理等价),但算法和用途完全不同、密钥对相互独立:
| ed25519(主密钥) | cv25519(加密子密钥) | |
|---|---|---|
| 曲线形式 | twisted Edwards | Montgomery(X25519) |
| 算法 | EdDSA 签名 | ECDH 密钥交换 |
| 用途 | 签名/认证/证书(usage sc) | 加密(usage e) |
| 能否加密 | ❌ 签名算法无密钥协商能力 | ✅ 协商会话密钥 |
| 能否签名 | ✅ | ❌ |
"一条曲线,两种形态":Curve25519 最初以 Montgomery 形式(X25519)定义;Ed25519 签名所用的是它的 twisted Edwards 同构曲线,两者通过双有理变换互转,共享相同的基础群与安全参数。名字都带"25519"指的是同一条曲线,但一个在上面跑 EdDSA 签名、一个跑 ECDH 密钥交换——ed25519 与 cv25519 是两套完全独立的密钥对,之间没有任何数学关联(曲线相同 ≠ 密钥相同,公钥点不落在对方坐标系里,无法互推)。
为什么签名和加密必须分开:OpenPGP 规定同一密钥对的用途必须单一。若共用一对密钥,攻击者可诱导你"签名"任意密文(签名 = 解密的数学逆运算),等于借你的手解密。分离后主密钥 [SC] 只做签名/认证/证书,加密永远走独立子密钥;加密子密钥泄露可单独吊销,不影响身份签名与信任链。
生成时的默认行为:gpg --full-generate-key 选 ECC 时默认组合即 ed25519 主密钥(usage sc)+ cv25519 加密子密钥(usage e);想自定义算法/能力用 --expert。老版本(GnuPG < 2.1,2016 年前)不支持 ECC,只能 RSA。
查看密钥结构:
gpg --list-keys --with-keygrip <key-id>
# pub ed25519 2025-07-18 [SC]
# 指纹:主密钥指纹
# keygrip:主密钥 keygrip
# sub cv25519 2025-07-18 [E]
# 指纹:子密钥指纹
# keygrip:子密钥 keygrip
# 每个 sub 行是一组独立的公私钥对;加密时 gpg 自动选用第一个可用的 [E] 子密钥
安全强度对比:255-bit 椭圆曲线群 ≈ RSA 3072 的安全强度,但签名短(64 字节 vs RSA-3072 的 384 字节)、密钥生成快、CPU 占用低。代价是兼容性:ECC 公钥无法被不支持它的旧客户端使用(如部分 2016 年前的工具、老智能卡固件),而 RSA 是老牌通用选择。
Gnome Keyring 集成
SSH key 管理
apt install seahorse
# 首次 SSH 连接时提示输入 passphrase,可勾选"自动解锁"
Git 凭据
apt install libsecret
git config --global credential.helper /usr/lib/git-core/git-credential-libsecret
GnuPG 集成
echo 'pinentry-program /usr/bin/pinentry-gnome3' >> ~/.gnupg/gpg-agent.conf
使用 GPG 密钥进行 SSH 认证
gpg-agent 开启 enable-ssh-support 后可完全替代 ssh-agent:它在 S.gpg-agent.ssh socket 上讲标准 SSH agent 协议,ssh 客户端无感知,ssh/scp/git/ssh -A 转发全部照常工作。
enable-ssh-support是两种方式共同的前提(不是方式 A 专属步骤):它让 gpg-agent 创建S.gpg-agent.sshsocket、对外讲 SSH agent 协议。没有它,方式 A 的 sshcontrol 密钥不会暴露给 ssh,方式 B 的ssh-add也根本连不上端点。方式 A / B 的区别只在服务哪些密钥:sshcontrol 里登记的 GPG 密钥 vs ssh-add 导入的原生密钥副本,SSH 支持本身两者都需要。
方式 A:GPG 密钥直接用作 SSH 认证(sshcontrol)
用 GPG 密钥替代 SSH 密钥,减少需要管理的密钥文件。本地侧只需两步:登记 keygrip + 导出公钥上服务器:
echo enable-ssh-support >> ~/.gnupg/gpg-agent.conf
# 在 .bashrc 中设置(顺序不能反:GPG_TTY 必须最先导出)
export SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"
export GPG_TTY="$(tty)"
gpg-connect-agent updatestartuptty /bye >/dev/null
# 注意:updatestartuptty 依赖客户端发 OPTION ttyname(来自 GPG_TTY)。
# 不导出 GPG_TTY 时它静默返回 OK 但 startup_tty 为空, 导致 ssh-add 导入密钥时
# pinentry 起不来(报 Inappropriate ioctl / agent refused operation),
# 而 pass/gpg 等 gpg 路径因客户端自带 ttyname 不受影响, 极具迷惑性。
# 获取子密钥 keygrip
gpg --list-keys --with-keygrip
# 将 keygrip 添加到批准列表
echo <keygrip> >> ~/.gnupg/sshcontrol
# 查看已加载的 SSH 密钥
ssh-add -l
# 导出 SSH 公钥
gpg --export-ssh-key <key-id>
完整免密登录流程(服务器侧)
本地登记 sshcontrol 后,服务器侧只需把公钥放进 authorized_keys(服务器不需要任何 GPG 组件),等价 ssh-copy-id:
# 方法一:导出公钥(authorized_keys 格式)并管道直传
gpg --export-ssh-key <key-id> | ssh user@server \
'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
# 方法二:先落文件再 ssh-copy-id
gpg --export-ssh-key <key-id> > ~/.ssh/gpg-key.pub
ssh-copy-id -i ~/.ssh/gpg-key.pub user@server
gpg --export-ssh-key <key-id>输出即 OpenSSH 公钥行;<key-id>!加叹号可导出指定子密钥的公钥- 每台目标服务器都要装一次公钥
- 认证流程:
ssh→ 本地 ssh-agent(即 gpg-agent)用 sshcontrol 登记的密钥签名 → 服务器按authorized_keys验签;首次弹 pinentry 解锁,缓存期内免密
实测:主密钥 [SC] 无 [A] 能力也能用
gpg-agent 的 SSH 协议实现不校验密钥的 [A] 认证能力标记:把 [SC] 主密钥的 keygrip 写入 sshcontrol 后,ssh-add -L 立即出现对应身份、可正常签名。所以「必须有 [A] 子密钥」不是硬性要求。但规范做法仍是单独添加 [A] 认证子密钥,登记/导出子密钥——主密钥长期持有且可签名,泄露面与风险都更大:
gpg --edit-key <key-id>
# addkey → (8) Authenticate → 生成 ed25519 [A] 子密钥
# 把子密钥 keygrip 写入 sshcontrol; 导出用 gpg --export-ssh-key <key-id>!
与方式 B 的口令模型差异
方式 A 的密钥属于 GPG 密钥库(gpg --list-secret-keys 可见),主密钥与全部子密钥共享同一个主密钥口令——解锁/预设一次即可全部刷新;方式 B 导入的原生密钥每把保护口令独立。缓存刷新手段不同:GPG 密钥库可用 gpg-preset-passphrase 用主口令一次预设全部;原生密钥同样可以用 gpg-preset-passphrase 刷新,但必须逐把提供各自的保护口令(= 导入时设置的口令,可能 ≠ 文件口令),无法一次统一预设。注意:ssh-add 对 gpg-agent 已注册密钥无法刷新缓存——重加是 no-op、ssh-add -d 对 sshcontrol 密钥被 agent 忽略(卸载即重挂),详见「问答记录」。(详见「口令缓存机制」与「三种标识符」章节)。
方式 B:把已有 SSH 密钥导入 gpg-agent
~/.ssh/id_xxx 原件不动,直接用 ssh-add 导入(RSA/ECDSA/Ed25519 均支持,gpg-agent 会转成内部 keygrip 格式):
ssh-add ~/.ssh/id_ed25519 # 导入时若原密钥带口令,需输一次
# 之后可用 ssh-add -l 查看
与 ssh-agent 的关键差异:导入是持久化的。ssh-agent 每次重启(注销/重启)内存清空,所有密钥丢失,必须重新 ssh-add;gpg-agent 导入时把密钥加密落盘 private-keys-v1.d/ 并自动登记进 sshcontrol,重启后自动可用,不需要再添加。只有 ssh-add -d/-D 删除或手动从 sshcontrol 移除才会失效。
导入时设置的是 keystore 保护口令,不是原密钥口令:ssh-add 导入会弹两次 pinentry——第一次是 ssh-add 读原密钥文件的原口令(SSH_ASKPASS 阶段),第二、三次是 gpg-agent 要求为 private-keys-v1.d/ 副本新设一个保护口令并重复确认(SETDESC "请输入一个密码,以便于在 gpg-agent 的密钥存储中保护接收到的私钥")。之后每次使用该密钥,提供的是保护口令(原文件口令不再参与)。所以:
- gpg-agent 没有自己的口令(无"gpg-agent 口令"概念),每把密钥的
.key文件各自独立上锁 - 不同密钥可设不同保护口令,TTL 缓存各自独立;实际建议全部统一为一个口令,体验等价"一个口令管所有"
- 导入无口令密钥时同样会要求设置保护口令(gpg-agent 不允许 keystore 副本明文)
修改 keystore 保护口令(官方 PASSWD 命令):gpg-agent 提供官方改口令命令(源码 agent/command.c 的 PASSWD),必须验证旧口令才能设置新口令,比"删除 .key 重新导入"更安全规范:
# 1. 找到 keygrip(文件名即 keygrip)
ls ~/.gnupg/private-keys-v1.d/
# 2. 改口令(交互式, 弹 pinentry: 先输旧口令验证 → 再输新口令两次确认)
gpg-connect-agent "PASSWD <keygrip>" /bye
要点:
- 全程按 keygrip 操作,不区分 GPG/SSH 密钥,对 ssh 导入的密钥同样适用
- 源码确认
PASSWD用CACHE_MODE_IGNORE强制询问旧口令(不受 TTL 缓存影响);随后agent_protect_and_store用新口令重写.key文件 - 可选参数:
--preset把新口令加入缓存;--verify只验证口令不改 - 不要用"删 .key 文件再重新导入"的方式改口令:不验证旧口令、有误删风险,且只删登记不删文件时 gpg-agent 会静默复用旧副本(不会弹新口令)
查看 gpg-agent 管理了哪些密钥:
export SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"
ssh-add -l # SSH 密钥指纹; -L 显示公钥
gpg --list-secret-keys --keyid-format long # GPG 私钥(sec/ssb)
ls ~/.gnupg/private-keys-v1.d/ # 实际私钥文件(keygrip.key), SSH/GPG 都在这里
grep -v '^#\|^$' ~/.gnupg/sshcontrol # 允许用作 SSH 的 keygrip
pinentry-program 必须是绝对路径
实测:gpg-agent 不做 PATH 搜索,pinentry-program pinentry-curses(纯名字)会报 can't exec 'pinentry-curses';pinentry-program /usr/bin/pinentry-curses(绝对路径)正常。想放自定义同名程序靠 PATH 回退不可行,回退逻辑必须由 wrapper 脚本内部完成。
pinentry 如何拿到 DISPLAY / tty(gpg-agent 源码验证)
pinentry 由 gpg-agent 后台进程启动,自身环境没有 DISPLAY/tty,信息靠两条路传递(源码 agent/call-pinentry.c 的 start_pinentry()):
- DISPLAY:gpg 客户端连 agent 时通过 assuan 发
OPTION display=...(实测gpg --debug=all可见);gpg-agent 收到后存进 session_env,启动 pinentry 时以 argv--display <值>传入(同时 atfork setenv DISPLAY,供 QT_QPA_PLATFORMTHEME 等工具用) - ttyname:gpg 客户端发
OPTION ttyname=/dev/pts/N(或GPG_TTY环境),gpg-agent 转发为 assuanOPTION ttyname=...给 pinentry
所以"gpg-agent daemon 环境没有 DISPLAY"不影响 pinentry-gnome3——显示信息走的是客户端 → agent → pinentry 参数链,与 agent 自身环境无关。
pinentry-gnome3 的依赖与回退(Gcr prompter)
实测 ldd /usr/bin/pinentry-gnome3:不依赖 GTK/Wayland,而是 libgcr-base-3(Gcr prompter)+ libncursesw。其 GUI 弹窗机制:
pinentry-gnome3 → D-Bus → Gcr System Prompter(gnome-keyring 桌面服务) → 桌面弹窗
└─ 无 prompter → fallback curses → 需要 tty
- 桌面会话(GNOME 桌面 + gnome-keyring):Gcr prompter 可用 → GUI 弹窗(X11/Wayland 由桌面决定)
- SSH 会话 / 无桌面:无 Gcr prompter(实测输出 "No Gcr System Prompter available, falling back to curses")→ fallback curses
- fallback 目标是 curses(tty 文本模式),不是另一个 GUI;askpass 无 tty 时 fallback 后依然失败
- 因此:桌面场景
pinentry-program /usr/bin/pinentry-gnome3体验最好;SSH/无头场景它自动等价于 curses;无 tty 无显示的 askpass 场景两者都失败,靠 sudo-askpass 层兜底提示(去终端手动pass show sudo解锁)
SSH 场景实测失效:WAYLAND_DISPLAY 污染让 gnome3 误判"有 GUI"(2026-08-09)
上面"SSH 会话 → fallback curses"的结论在实际 SSH 登录下并不可靠。前提"SSH 会话无 Gcr prompter"在本机不成立:SSH 会话与桌面会话共享同一 user session bus(/run/user/<uid>/bus),org.gnome.keyring.SystemPrompter 在总线上是存在的——所以 gnome3 走 GUI 路径后,弹窗出现在本地桌面,远程用户看不见,干等超时。
现象:git pull 报 sign_and_send_pubkey ... agent refused operation;gpg-agent 日志反复 ssh sign request failed: 超时 <Pinentry>;pinentry-gnome3 进程被拉起却没有任何提示。
根因:pinentry-gnome3 只要看到非空 WAYLAND_DISPLAY(或 DISPLAY)就判定"有 GUI",跳过 Gcr prompter 不可用时的 curses 回退。SSH 场景这个值有两个来源,永远非空:
- gpg-agent 继承 systemd user manager 环境:
systemctl --user show-environment里有真实桌面的WAYLAND_DISPLAY=wayland-0、DISPLAY=:0、XAUTHORITY=...mutter...。注意systemctl --user show gpg-agent显示Environment=为空不代表没有——systemd 服务会继承 manager 环境,只看show的显式设置会被误导。 - UPDATESTARTUPTTY 传客户端的 WAYLAND_DISPLAY:
~/.ssh/config每次 ssh 前跑gpg-connect-agent UPDATESTARTUPTTY /bye,会把发起 ssh 的客户端进程的 WAYLAND_DISPLAY 原样传给 gpg-agent。tmux server 启动时继承旧会话的孤儿 socket(如wayland-dPTUuQSK),wlyank.sh 旧版兜底还会换成"最新"的死 socket(如wayland-XiKmbyg7)——实测 pinentry 拿到过这个孤儿值。
结果:连死 socket → 窗口永远创建不出来;拿 wayland-0 → 弹在本地桌面、远程看不见。从 SSH 视角都是"没有正常弹窗"。
结论:SSH 场景别指望 pinentry-gnome3 自动回退 curses,固定 pinentry-program /usr/bin/pinentry-curses(提示画在发起 ssh 的终端)。配套清理:.my_zshrc 在 tty 会话 unset WAYLAND_DISPLAY;wlyank.sh 兜底改为用 .lock 的 flock 检测合成器存活。若想保留 gnome3,需给 gpg-agent 加 systemd drop-in UnsetEnvironment=WAYLAND_DISPLAY DISPLAY 让回退恒生效(效果等同 curses)。gpg-agent 无"按连接选 pinentry"的能力,桌面 GUI 弹窗与 SSH 终端提示无法一套配置两全。
环境变量配置(SSH_AUTH_SOCK)
SSH_AUTH_SOCK 是给 ssh 客户端进程看的(告诉它去哪找 agent socket),不是 agent 自己需要的——所以不是启动 agent 时配置,而是写进 shell 配置文件,每个新会话自动生效:
# ~/.zshrc 或 ~/.bashrc(写一次)
export SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"
gpg-connect-agent updatestartuptty /bye >/dev/null # 告诉 agent 当前 tty,pinentry 弹对地方
- gpg-agent 无需手动启动(systemd socket 激活,用到才拉起);socket 路径固定(
/run/user/<uid>/gnupg/S.gpg-agent.ssh),可动态查询 - 只影响新会话:正在运行的终端不生效,重开终端或
source后生效 - 桌面(GNOME/systemd)常自动启动 ssh-agent,切换后先确认
echo $SSH_AUTH_SOCK指向 gpg-agent 的路径,否则 ssh 走错 agent - 遗留的 ssh-agent 自动启动逻辑同样会劫持:迁移 gpg-agent 方案前写的老 rc 函数(如
sshagent()+~/.pid_ssh_agent持久化)可能在某次ssh-add -l失败时启动独立 ssh-agent 并export SSH_AUTH_SOCK,此后所有新 shell 都 source 复用这个空 agent,gpg-agent 被彻底绕过,且keyinfo缓存一切正常极具迷惑性(详见问答 Q7) - 验证:
echo $SSH_AUTH_SOCK(应含gnupg/S.gpg-agent.ssh)+ssh-add -l(应列出密钥)
与 GNOME Keyring(Ubuntu 桌面默认)的对比
Ubuntu + GNOME 桌面的默认 SSH agent 是 gnome-keyring 的 gcr-ssh-agent(/run/user/<uid>/keyring/ssh),不是 OpenSSH ssh-agent——ssh-agent 只在无桌面/服务器环境才是默认。GNOME 通过 PAM(pam_gnome_keyring.so)在图形登录时拉起它并设置 SSH_AUTH_SOCK。
| GNOME Keyring | gpg-agent | |
|---|---|---|
| 存储 | login.keyring 加密文件 | private-keys-v1.d 加密文件 |
| 持久化 | ✓ 重启不丢 | ✓ 重启不丢 |
| 登记密钥 | 隐式:首次 ssh 自动弹 GUI 框,勾选"自动解锁"即登记 | 显式一次:ssh-add 或写 sshcontrol |
| 解锁时机 | 仅 GUI 登录时(PAM 用登录密码) | 任意时刻,终端/无头均可 |
| 解锁交互 | 只能 GUI 对话框 | pinentry-curses 终端输入 / pinentry-gnome3 弹框 |
| 解锁窗口 | 整个 GUI 会话(登录→注销) | TTL 可配(闲置 + 绝对上限) |
| 无 GUI 环境 | 不可用(SSH 远程、无头全废) | 可用 |
要点:
- keyring 的解锁交互只能在 GUI 上发生:SSH 终端里输不了,无头远程时对话框挂死;gpg-agent 的 pinentry-curses 是终端输入,不受 GUI 限制
- gpg-agent 没有隐式登记机制:它只认 keystore 里的密钥(agent 根本看不到
~/.ssh/id_xxx文件),登记必须显式一次;之后首次使用自动弹 pinentry 解锁,体验等同 keyring 的"自动解锁" - 若想要 keyring 那种 GUI 弹框手感:gpg-agent.conf 改
pinentry-program /usr/bin/pinentry-gnome3(无 GUI 时可切回 curses)
为什么 ssh-agent 启动时输出变量,而 gpg-agent 是固定路径?
两种设计哲学,核心差异是"安全性靠什么、谁能找到 agent":
| ssh-agent | gpg-agent | |
|---|---|---|
| 时代 | 1995 年,无 systemd/XDG 规范,共享 /tmp | 2007 年(GnuPG 2.x),已有每用户 runtime 目录规范 |
| 发现机制 | 环境变量传播(必须 shell 子进程) | 固定路径约定(任何同用户进程可发现) |
| 多实例 | 支持(会话隔离是特性) | 单例(一个用户一个 daemon) |
| 安全 | 路径不可猜 + 会话隔离 | 文件权限(0700)+ 用户隔离 |
| 代价 | cron/服务/转发场景要特殊处理(agent forwarding 就是补丁) | 同用户进程都能连 |
ssh-agent 的随机路径(/tmp/ssh-XXXX/agent.PID)是 90 年代共享目录下的生存策略:路径不可预测防猜解 + 每个会话独立实例,所以启动时必须用 eval $(ssh-agent) 把随机路径和 SSH_AGENT_PID 传回 shell。gpg-agent 是"全家的钥匙柜固定放在老地方":路径固定意味着 systemd 能 socket 激活、cron/服务等无 shell 环境的调用方也能靠约定找到它,安全靠目录权限而非路径隐藏。
与经典 ssh-agent 的差异:密码提示在哪一侧(常见误区)
误以为 gpg-agent 当 ssh-agent 时行为等同 ssh-agent,实际密码提示的归属完全不同:
| 经典 ssh-agent | gpg-agent | |
|---|---|---|
| ssh-add 时 | ssh-add 自己读密钥、在当前终端提示,把解密后的私钥交给 agent | 输完原口令后,gpg-agent 再拉 pinentry 为落盘副本设保护口令 |
| 密钥存放 | 只存内存,重启即失 | 加密落盘 ~/.gnupg/private-keys-v1.d/,重启不丢 |
| ssh 认证时 | 用内存解密密钥签名,永不提示 | 缓存过期后要解密落盘副本 → 再次弹 pinentry |
| 提示归属 | 客户端(ssh-add 所在终端) | agent 守护进程(要猜画到哪个终端) |
- ssh-agent:提示永远发生在
ssh-add的调用终端(客户端行为),agent 只签名不碰密码 → 天然没有"弹窗错位",也不需要GPG_TTY/updatestartuptty这套机制。代价:agent 重启(注销/重启)密钥全丢,每次登录都要重新ssh-add。 - gpg-agent:持久化换来重启不丢密钥,但 SSH 协议不带 ttyname,agent 只能靠全局 startup_tty → 要密码的时机从 ssh-add 扩散到缓存过期后的任意 ssh 操作。
多终端弹窗错位(pinentry-curses 特有,常见坑):startup_tty = "最后一次执行 updatestartuptty 的终端"。每次开 shell 都跑一次,最后 source 的那个终端成为唯一目标:终端 A 跑命令、弹窗却画到终端 B。缓解:
- 只在密钥未缓存时弹:
default-cache-ttl-ssh/max-cache-ttl-ssh配 12h 后 ssh 认证基本零弹窗; - gpg/pass 路径不受影响:gpg 客户端每次连接自带 ttyname,弹窗始终跟随执行命令的终端,
pass show sudo永远不错位; - pinentry-gnome3 桌面场景不受影响:走 Gcr prompter GUI,与终端无关;
- 想精确跟随:
~/.ssh/config加下面一行,让每次 ssh 前把 startup_tty 刷新为当前终端(前提:rc 已export GPG_TTY=$(tty))。run.sh 的install_gpg_agent已自动写入本配置:
语法坑:Match host * exec "gpg-connect-agent UPDATESTARTUPTTY /bye"exec是 Match 的条件(criterion),必须写在Match行内,不能缩进另起一行当块内指令——后者实测报Bad configuration option: exec(OpenSSH 8.4+ 才有此关键字,验证用ssh -G -F <config> localhost)。
一句话:ssh-agent 靠"内存解密密钥 + 提示留在客户端"回避错位;gpg-agent 靠持久化换便利,代价是 GPG_TTY/updatestartuptty 这套弹窗定位机制。
口令缓存机制(TTL,易踩坑)
gpg-agent 有两对独立计时器,只配一个会踩坑:
| 计时器 | 默认 | 语义 |
|---|---|---|
default-cache-ttl | 600s | 闲置超时,每次使用重置 |
max-cache-ttl | 7200s | 绝对上限:从缓存创建起算,连续使用也照样过期 |
default-cache-ttl-ssh | 1800s | SSH 密钥专属闲置超时 |
max-cache-ttl-ssh | 7200s | SSH 密钥专属绝对上限 |
- 缓存寿命 = min(创建起
max-cache-ttl,最后使用起default-cache-ttl)。只配default-cache-ttl 86400时,max-cache-ttl默认 2 小时且从创建起算——无论闲置还是连续使用,最长 2 小时必然过期重输,那行配置基本形同虚设,必须同时调max-cache-ttl;且 SSH 用的是-ssh后缀那对(默认 30 分钟/2 小时),配default-cache-ttl对 SSH 完全不生效 - 想要"一天一次"必须四行全配:
default-cache-ttl/max-cache-ttl/default-cache-ttl-ssh/max-cache-ttl-ssh各 86400 max-cache-ttl没有 86400 硬上限:源码(gpg-agent.c)只把它当默认值,配置值无 clamp,设 604800(一周)也合法- TTL 长短 = 安全权衡:密钥被盗后的"冒充窗口"长度
gpgconf --reload gpg-agent会清空全部口令缓存(实测:预设后缓存位1,reload 后变-)——配置未变更时不要 reload,否则未过期的缓存被白白清掉(gpg-manager.sh 已改为仅配置变更时才 reload);kill+launch重启同理清空缓存gpg-preset-passphrase -c(preset)不刷新 max-cache-ttl 计时:max-cache-ttl 锚定缓存条目的created(首次创建时刻),preset 只更新last_stored(即只重置 default-cache-ttl 的闲置计时器)。man page 原话:"expired even if it has been accessed recently or has been set using gpg-preset-passphrase"。想用定时 preset"续命"绕过 max-cache-ttl 是行不通的- 唯一能重置 max-cache-ttl 的方式 = 删条目重建:
gpg-connect-agent "CLEAR_PASSPHRASE <grip>" /bye(等价 forget)后条目消失,再 preset 即创建全新条目(created重置)→ 两个计时器都从零起算。注意本环境gpg-preset-passphrase -f(forget)实测报IPC 无查询回调bug,用上面的 agent 协议命令可靠 - 当前本仓库实际配置(run.sh install_gpg_agent 模板):default 6h(21600)+ max 16h(57600),GPG 与 SSH 各一对;改 TTL 必须同步 run.sh 模板,否则重跑
bash run.sh default会被 sed 覆盖回旧值并 reload 清缓存
三种标识符:keyid / keygrip / SHA256 指纹
同一把密钥在 GPG 生态里有三种视角,长度和用途都不同,容易混淆(gpg-agent-manager 输出里三种同时出现):
| 标识符 | 长度/形式 | 是什么 | 出现位置 |
|---|---|---|---|
| keyid | 16 hex,如 9FCAF6A748F642BA | GPG 指纹(40 hex)的末尾 16 位(截断短 ID) | gpg --list-secret-keys 的 sec 行、--keyid-format |
| keygrip | 40 hex,如 D2BA4B39…B3803 | SHA-1(公钥) 的十六进制,gpg-agent 对密钥材料的唯一标识 | 私钥文件名 ~/.gnupg/private-keys-v1.d/<grip>.key、sshcontrol 登记行、gpg-preset-passphrase 参数 |
| SHA256 指纹 | SHA256:ghY5…(base64 43 位) | 公钥的 SHA-256 哈希(base64 编码) | ssh-add -l、ssh-keygen -lf、SSH 指纹校验 |
要点:
- GPG 另有全指纹(40 hex,
gpg --fingerprint),keyid 只是它的末尾截断(理论上可碰撞) - keygrip 与指纹算法不同(SHA-1 vs SHA-256)、编码不同(hex vs base64),不能直接互转
- keygrip 与 UID/邮箱/改名无关,故 gpg-agent 用做内部键:私钥文件命名、sshcontrol 登记、口令缓存键
- 三处各用各的:给人看用 keyid,给 gpg-agent 用 keygrip,给 SSH 侧校验用 SHA256
坑:sshcontrol 行尾的可选字段
sshcontrol 登记行格式为 <keygrip> [缓存TTL] [标志](如 D2BA…B3803 0,0 为 TTL 默认值)。脚本抓取若取整行再 tr -d ' ',会把 0 粘到 keygrip 尾部(41 位脏键)——gpg-preset-passphrase 按脏键预设返回成功,但缓存落在不存在的键名下,真实密钥没被刷新(gpg-connect-agent 'keyinfo --list' /bye 可见真实键 cache 仍为空)。正确做法只取行首 40 位十六进制:grep -oE '^[0-9A-Fa-f]{40}'。
pinentry 超时(防止弹窗挂死)
pinentry-timeout 控制 pinentry 弹窗在无用户输入多少秒后自动关闭(等效点击"取消"),避免无头/askpass 场景下 pinentry 永久挂起:
# ~/.gnupg/gpg-agent.conf
pinentry-timeout 15
- 生效原理:gpg-agent 源码
if (opt.pinentry_timeout)判断后,通过 assuan 发送SETTIMEOUT 15,pinentry 收到后 15 秒无输入自动关闭并返回错误 - 超时起点:从弹窗出现后最后一次用户输入开始计时,一直打字会不断重置计时器,不会中途打断
- 已知 bug 仅影响 0:设 0 时
opt.pinentry_timeout为假,gpg-agent 不发送超时指令,pinentry 回退内部默认(通常 60 秒);正数值(如 15)正常生效 - pinentry 兼容性:官方文档称 pinentry "may or may not honor this request",但主流实现(curses/gtk-2/qt/gnome3)都支持
- 与 TTL 的区别:TTL 管"缓存过期后重新询问",pinentry-timeout 管"弹窗本身无响应时自动关"
Agent 上下文:GPG_TTY 指向不存在路径让 pinentry 快速失败(2026-08-09 端到端实测)
背景:opencode 等 agent 在终端里运行时,agent 触发的 ssh/git/pass 操作若口令已过 TTL,pinentry-curses 会把口令框画到 opencode 的 TUI 上打乱显示,或指向失效终端挂起。约定:agent 给每条可能触发口令的命令前缀 GPG_TTY=/dev/nonexistent(SSH 路径再靠 Match exec updatestartuptty 把该值带进 startup_tty),让 pinentry 快速失败而非画框。
端到端实测(gpgconf --kill gpg-agent 清空缓存后):
GPG_TTY=/dev/nonexistent gpg-connect-agent updatestartuptty /bye
GPG_TTY=/dev/nonexistent ssh -T git@gitee.com
# 1 秒内失败: sign_and_send_pubkey ... agent refused operation, 不画框不挂起
踩坑:/dev/null 反而挂满 15s。pinentry-curses 的 tty 来源不同,行为天差地别:
| 传给 pinentry 的 tty | 行为 |
|---|---|
OPTION ttyname=(空) | 立即失败 Inappropriate ioctl |
OPTION ttyname=/dev/null | 挂满 pinentry-timeout(/dev/null 是真实设备,open 成功卡在 termios 操作上) |
OPTION ttyname=/dev/nonexistent | 立即失败 No such file or directory |
GPG_TTY=/dev/nonexistent(env) | 立即失败 |
SSH 签名走 OPTION ttyname 路径,所以必须用不存在的路径,不能用 /dev/null。
两个必要条件(缺一无效):
- 逐条命令前缀,不能只更新一次:ssh 的
Match exec每次连接都会按那条命令进程的 GPG_TTY 重置 startup_tty,命令没带/dev/nonexistent会被重置回真实终端,照常画框。 - 仅口令已过期时生效:缓存期(TTL)内无需 pinentry,命令照常成功。
口令过期快速失败后,让用户在自己的终端用正确 GPG_TTY=$(tty) 解锁一次(如 pass show sudo)刷新缓存,agent 重试即可。
私钥存储与保护
~/.ssh/id_xxx是原件(OpenSSH 标准格式);gpg-agent 持有的是工作副本,加密落盘在~/.gnupg/private-keys-v1.d/.key文件的加密是纯对称 s2k:口令 + 文件内 salt 迭代派生密钥(源码 protect.c 的hash_passphrase),解密只需要.key文件 + 口令,不需要 GPG 公私钥对- 格式与 gpg-agent 版本耦合,备份建议整个
~/.gnupg/;备份文件与口令要分开存放(防丢失两者都得保住,防盗两者不能同处) - 无头环境可用:
pinentry-program /usr/bin/pinentry-curses,不依赖桌面
私钥无口令 ≠ 数据明文:pass 里保存的 sudo 密码始终是公钥加密的密文(sudo.gpg),与私钥有没有口令无关。私钥无口令只是 .key 文件本身明文(仍在 ~/.gnupg 0700 权限保护下),少了最后一道防线:
| 泄露组合 | 私钥有口令 | 私钥无口令 |
|---|---|---|
仅 sudo.gpg | 解不开 | 解不开 |
sudo.gpg + .key | 解不开(无口令) | 密码直接泄露 |
给 GPG 主密钥设口令(gpg --change-passphrase 设的即是 .key 文件保护口令,也是以后使用时要输的,只有一个口令)后由 gpg-agent 保管:加密落盘 + 解锁状态内存缓存。架构上 gpg 客户端不直接碰私钥,解密/签名必须经 gpg-agent 中转,所以 gpg-agent 始终必需;有口令时它是"守卫",无口令时只是"透明通道"。
安全模型
- gpg-agent 是用户级单例:同一用户的所有会话(TTY/GUI/SSH)共享同一个缓存,一次解锁处处可用
- 缓存期内:任何同用户进程可免口令请求 agent 签名(可 ssh 到所有信任该密钥的机器)——这是"冒充窗口"
- 但无法提取密钥本体:agent 协议没有导出私钥的指令;
private-keys-v1.d是密文;同 UID 进程也无法 ptrace 读 agent 内存(Ubuntu 默认 yama ptrace_scope=1 只允许 tracee 的祖先进程,或持有 CAP_SYS_PTRACE 的进程) - 结论:删掉
~/.ssh/id_xxx原件后,攻击者拿不到长期密钥、过期后也无法继续用,只有 TTL 窗口内的"冒充使用权"
Git Tag 签名
git config --global user.signingkey <key-id>
git tag -s v1.0.0
Git 凭据管理
安装 Git Credential Manager 后配置:
# 使用 GPG 存储凭据
export GCM_CREDENTIAL_STORE=gpg
# 或使用内存缓存
export GCM_CREDENTIAL_STORE=cache
# 初始化密码存储
pass init <gpg-key-id>
在 Emacs 中需设置环境变量:(setenv "GCM_CREDENTIAL_STORE" "cache")
替代 apt-key
从 Ubuntu 22.04 起 apt-key 被弃用,改用 GPG:
# 旧方式(弃用)
wget -nc https://example.com/key.gpg
sudo apt-key add key.gpg
# 新方式
wget -O- https://example.com/key.gpg | gpg --dearmor | \
sudo tee /etc/apt/trusted.gpg.d/example.gpg > /dev/null
# 更新 sources.list 添加 signed-by
deb [arch=amd64 signed-by=/etc/apt/trusted.gpg.d/example.gpg] https://example.com/debian stable main
迁移旧 apt-key 条目:
sudo apt-key list # 查看所有旧 key
sudo apt-key export <key-id> | sudo gpg --dearmor -o /usr/share/keyrings/<name>.gpg
sudo apt-key del <key-id> # 删除旧条目
常见问题
- GPG 生成密钥时提示熵不够:SSH 远程服务器缺乏随机源,安装
rng-tools或在 GUI 环境下操作 - Emacs Magit 与 git credential 冲突:设置
magit-process-connection-type为 t,配置GCM_CREDENTIAL_STORE=cache - 脚本输入口令后挂死:终端残留 pinentry-curses 遗留的 raw 模式(
stty -a显示-icanon)时,按 Enter 只发送\r,而 bashread等待\n→ 输入口令后永远等不到换行。恢复:reset或stty sane;脚本侧可在 read 前检测stty -a </dev/tty | grep -oE '[+-]?icanon' | grep -q '^-'命中即自动stty sane自愈(gpg-manager.sh 已内置)。典型来源:pinentry-curses 弹错终端(GPG_TTY 未导出)或被killall pinentry-curses强杀后未恢复终端属性。 - SSH 场景 pinentry 弹到别的终端:SSH 协议不携带 ttyname,pinentry-curses 只能画在 gpg-agent 全局 startup_tty(最后一次
updatestartuptty的终端),多终端下最易弹错位置;gpg/pass路径客户端自带 ttyname 不受影响。修复:shell rc 里export GPG_TTY="$(tty)"(有 tty 时)并gpg-connect-agent updatestartuptty /bye,详见「pinentry 如何拿到 DISPLAY / tty」。
问答记录
Q1: 脚本交互输入口令后卡死(输入了口令但无任何响应)?
终端残留 pinentry-curses 遗留的 raw 模式(stty -a 显示 -icanon)导致:按 Enter 只发送 \r,而 bash read 等待 \n → 永远等不到换行。典型来源:pinentry-curses 弹错终端(GPG_TTY 未导出)或被 killall pinentry-curses 强杀后未恢复终端属性。
- 手动恢复:
reset或stty sane - 脚本侧修复(gpg-manager.sh 已内置):read 前无条件
stty sane </dev/tty——实测先做"检测到-icanon才恢复"的方式在真实环境没兜住(检测路径未必覆盖所有遗留状态),无条件恢复等价reset且终端正常时无副作用,彻底杜绝该挂死类问题 - 附带验证结论:仅
-icrnl(ICANON 开、CR 不转 NL)不会挂 bash read,真正挂死的是-icanon
Q2: gpg-preset-passphrase 支持从 stdin 读口令吗?为什么脚本还要先 read 一遍?
支持,脚本正是在用它:printf '%s\n' "$passphrase" | gpg-preset-passphrase -c "$grip"。但 read 不能省:
- 一次输入要复用多次:主密钥+子密钥 = 多次 preset 调用,每次调用消耗一份 stdin;直接交终端 stdin 给第一次调用,后续 grip 就没口令可读(阻塞)
- 静默输入:preset 直接读终端 stdin 不回显屏蔽(仅 fread stdin),口令会明文显示;
read -s提供静默输入
分工:read(终端侧:静默收一次输入)→ shell 变量 → 循环内每次 preset 走 stdin(agent 侧)。变量存口令不构成泄露(非 export 不进 environ、不落 argv,ps 不可见,循环后 unset),详见上文「安全模型」。
Q3: SSH 场景 pinentry 弹到别的终端?
SSH 协议不携带 ttyname,pinentry-curses 只能画在 gpg-agent 全局 startup_tty(最后一次 updatestartuptty 的终端),多终端下最易弹错位置;gpg/pass 路径客户端自带 ttyname 不受影响。修复:shell rc 里 export GPG_TTY="$(tty)"(有 tty 时)并 gpg-connect-agent updatestartuptty /bye,详见「pinentry 如何拿到 DISPLAY / tty」。
Q4: GPG 生成密钥时提示熵不够?
SSH 远程服务器缺乏随机源,安装 rng-tools 或在 GUI 环境下操作。
Q5: Emacs Magit 与 git credential 冲突?
设置 magit-process-connection-type 为 t,配置 GCM_CREDENTIAL_STORE=cache。
Q6: gpg-preset-passphrase 支持交互式输入口令吗?怎么验证?
支持,且纯回车即可完成,不需要 Ctrl+D。实测(pty 模拟 + 真实 grip 的 keyinfo 缓存位验证):
| 输入方式 | 结果 |
|---|---|
| 口令 + 回车(无 EOF) | exit 0,缓存位 -→1 |
| 口令 + Ctrl+D | exit 0 |
| 口令 + 回车 + Ctrl+D | exit 0 |
程序按行读取 stdin(非 fread 到 EOF),回车即结束输入;尾部 \n 会被剥离——反证:printf '%s\n' "$pass" | gpg-preset-passphrase -c <grip> 喂入后实际签名成功,说明缓存口令与真实口令一致、无混入换行。
验证方法:
gpg-preset-passphrase -c <40位keygrip> # 直接运行, 盲输口令, 回车
gpg-connect-agent 'keyinfo --list' /bye | grep <keygrip> # 第7字段 1=已写缓存(格式: grip 类型 序列 缓存 公钥 设备 口令缓存 来源 过期)
gpg-preset-passphrase -f <keygrip> # 验证完清理
常见误区(均已被实测推翻):
- ❌ "必须 Ctrl+D 结束输入"——回车即完成
- ❌ "回车会把
\n作为密码的一部分导致失败"——\n被剥离 - ❌ "参数带
SSH:前缀(如SSH:40位特征码)"——参数就是裸 40 位 hex keygrip(--help:gpg-preset-passphrase [选项] KEYGRIP) - ✅
read -s包装交互界面——正确做法,gpg-manager.sh 已实现
重要注意:preset 不校验口令正确性——"写入成功"(exit 0)≠ "口令正确"。写入错误口令后,下次签名解密失败会触发 pinentry 重新录入(自愈),但会造成一次多余的弹窗。
Q7: gpg-agent 缓存明明正常(keyinfo 显示 cached=P),为什么 git push 还是 Permission denied (publickey)?
排查思路要分两层:密钥缓存(gpg-agent 侧)≠ 认证通路(SSH_AUTH_SOCK 指向)。实测案例:同一台机器 git push(gitee)成功、git push(github)失败,gpg-connect-agent 'keyinfo --list' /bye 显示密钥缓存正常(P = 口令已 preset),一度误判为"缓存过期"。
真正原因:当前 shell 的 SSH_AUTH_SOCK=/tmp/ssh-XXXX/agent.PID 指向一个残留的空 ssh-agent(ssh-add -l 报 no identities),gpg-agent 被完全绕过。来源是迁移 gpg-agent 方案前的老 rc 函数 sshagent()(~/.my_profile.net):某次 shell 启动时 ssh-add -l 失败(当时 gpg-agent 未就绪),它便启动独立 ssh-agent -s 并把 SSH_AUTH_SOCK/SSH_AGENT_PID 持久化到 ~/.pid_ssh_agent,此后的每个 shell 都 source 复用这个空 agent——这就是 ~/.pid_ssh_agent 和 /tmp/ssh-*/agent.* 出现在 SSH_AUTH_SOCK 里的来源。
为什么 gitee 能 push、github 不能:~/.ssh/config 配了 ControlMaster auto + ControlPersist 1h,gitee 已有复用的 master 连接(~/.ssh/socket/git@gitee.com-22),复用通道不重新认证;github.com 无复用连接,每次 push 都要实时签名 → 空 agent → 拒绝。所以"有的仓库能 push 有的不能"正是这个病的典型症状。
修复:
# 1. 清理残留 ssh-agent 与其持久化文件(进程号见 ~/.pid_ssh_agent 或 ps aux | grep ssh-agent)
kill <SSH_AGENT_PID> && rm -f ~/.pid_ssh_agent
# 2. 当前 shell 指回 gpg-agent
export SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"
根治:老 sshagent() 开头先探测 gpg-agent socket(gpgconf --list-dirs agent-ssh-socket 存在且 -S 为 socket)则直接 export SSH_AUTH_SOCK 并跳过启动独立 agent;无 gpg-agent 的环境(如远程服务器)再走原逻辑。教训:SSH_AUTH_SOCK 是环境变量覆盖机制,任何会 export 它的旧逻辑都会静默劫持新方案;排查认证失败第一步永远先 echo $SSH_AUTH_SOCK 确认指向(应含 gnupg/S.gpg-agent.ssh),ssh -vvv 的 get_agent_identities 行可直接看到 agent 里的身份数。
Q8: 跑完 gpg-manager.sh,某把密钥的 keyinfo 缓存位还是 -(没被预设)?
先看这把密钥有没有 .pub 文件:gpg-manager.sh 第 5 步 B 分支(原生 SSH 密钥缓存刷新)只从 ~/.ssh/*.pub 构建「指纹 → 私钥文件」映射,无 .pub 的密钥(如 id_rsa_199_jumper,只有私钥和 .bak)找不到私钥文件,会被 [!] 跳过: 未找到 keygrip ... 对应的私钥文件 跳过,不执行预设——属预期行为,无 .pub 的密钥不纳入脚本管理。
处理方式二选一:
# 1. 手动预设(不依赖 .pub, 在交互终端输入该密钥的保护口令)
gpg-preset-passphrase -c <40位keygrip> # 之后 keyinfo 缓存位 - → 1
# 2. 补一份 .pub 后重跑 gpg-manager.sh(脚本会识别并纳入管理)
ssh-keygen -y -f ~/.ssh/id_xxx > ~/.ssh/id_xxx.pub
要点:无 .pub 不影响密钥本身使用(认证只读私钥),只是脱离 gpg-manager.sh 的管理(不参与缓存刷新)。另外 .bak 备份私钥本身就不该被管理——脚本的 ~/.ssh/id_* 遍历已排除 *.pub / *-cert / *.bak。
Q9: 智能体(agent)里 git push 弹 pinentry 没法输入口令,怎么办?
opencode 等智能体环境里,git push 走 SSH → gpg-agent(SSH_AUTH_SOCK)→ SSH 密钥口令缓存过期 → pinentry-curses 画在 agent 的 PTY 上,你根本没法输入 → push 挂死/失败。这是"SSH 密钥口令缓存过期"在 agent 场景的必然结果,不是 bug。
解法(按优先级):
- 拉长 TTL + 解锁一次管很久:
max-cache-ttl-ssh(和max-cache-ttl)调大(本仓库现为 57600 = 16h)。开机/agent 重启后先在你自己的终端解锁一次(ssh -T git@github.com走 SSH 密钥、pass show sudo走 GPG),之后 agent 里 push/sudo 长时间不再弹。改 TTL 需同步 run.sh 模板,否则重跑会被覆盖 - 需要立刻重置计时时用
gpg-manager.sh --forget:脚本先打印每把密钥当前缓存状态(KEYINFO第 7 字段:1=已缓存,-=未缓存),所有 GPG/SSH 密钥口令一致时只输入一次,逐把询问预设? [y/N];带--forget时预设前先CLEAR_PASSPHRASE删条目,使 max-cache-ttl 重新计时 - 也可在 agent 场景为命令前缀
GPG_TTY=/dev/nonexistent让 pinentry 快速失败而非画框(见上文「Agent 上下文」节),但那是"不弹"不是"能过",治标不治本
Q10: gpg-preset-passphrase 的 preset 与 forget 对 TTL 的影响?
| 操作 | default-cache-ttl | max-cache-ttl |
|---|---|---|
| preset(条目已存在) | ✅ 重置闲置计时器 | ❌ 不刷新(created 锚定) |
| forget → preset(条目重建) | ✅ 重新计时 | ✅ 重新计时 |
--forget(CLEAR_PASSPHRASE 删除条目)后再 preset,gpg-agent 视为全新缓存条目,两个计时器都从零开始。注意本环境 gpg-preset-passphrase -f 实测报 IPC 无查询回调,用 gpg-connect-agent "CLEAR_PASSPHRASE <grip>" /bye 更可靠。