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 的特殊规则(官方文档):

所以 --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 吊销

关键区别——信任模型:

所以每次 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 EdwardsMontgomery(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.ssh socket、对外讲 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

实测:主密钥 [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 的密钥存储中保护接收到的私钥")。之后每次使用该密钥,提供的是保护口令(原文件口令不再参与)。所以:

修改 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

要点:

查看 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()):

  1. DISPLAY:gpg 客户端连 agent 时通过 assuan 发 OPTION display=...(实测 gpg --debug=all 可见);gpg-agent 收到后存进 session_env,启动 pinentry 时以 argv --display <值> 传入(同时 atfork setenv DISPLAY,供 QT_QPA_PLATFORMTHEME 等工具用)
  2. ttyname:gpg 客户端发 OPTION ttyname=/dev/pts/N(或 GPG_TTY 环境),gpg-agent 转发为 assuan OPTION 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

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 场景这个值有两个来源,永远非空:

  1. 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 的显式设置会被误导。
  2. 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 弹对地方

与 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 Keyringgpg-agent
存储login.keyring 加密文件private-keys-v1.d 加密文件
持久化✓ 重启不丢✓ 重启不丢
登记密钥隐式:首次 ssh 自动弹 GUI 框,勾选"自动解锁"即登记显式一次:ssh-add 或写 sshcontrol
解锁时机仅 GUI 登录时(PAM 用登录密码)任意时刻,终端/无头均可
解锁交互只能 GUI 对话框pinentry-curses 终端输入 / pinentry-gnome3 弹框
解锁窗口整个 GUI 会话(登录→注销)TTL 可配(闲置 + 绝对上限)
无 GUI 环境不可用(SSH 远程、无头全废)可用

要点:

为什么 ssh-agent 启动时输出变量,而 gpg-agent 是固定路径?

两种设计哲学,核心差异是"安全性靠什么、谁能找到 agent":

ssh-agentgpg-agent
时代1995 年,无 systemd/XDG 规范,共享 /tmp2007 年(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-agentgpg-agent
ssh-add 时ssh-add 自己读密钥、在当前终端提示,把解密后的私钥交给 agent输完原口令后,gpg-agent 再拉 pinentry 为落盘副本设保护口令
密钥存放只存内存,重启即失加密落盘 ~/.gnupg/private-keys-v1.d/,重启不丢
ssh 认证时用内存解密密钥签名,永不提示缓存过期后要解密落盘副本 → 再次弹 pinentry
提示归属客户端(ssh-add 所在终端)agent 守护进程(要猜画到哪个终端)

多终端弹窗错位(pinentry-curses 特有,常见坑):startup_tty = "最后一次执行 updatestartuptty 的终端"。每次开 shell 都跑一次,最后 source 的那个终端成为唯一目标:终端 A 跑命令、弹窗却画到终端 B。缓解:

一句话:ssh-agent 靠"内存解密密钥 + 提示留在客户端"回避错位;gpg-agent 靠持久化换便利,代价是 GPG_TTY/updatestartuptty 这套弹窗定位机制。

口令缓存机制(TTL,易踩坑)

gpg-agent 有两对独立计时器,只配一个会踩坑:

计时器默认语义
default-cache-ttl600s闲置超时,每次使用重置
max-cache-ttl7200s绝对上限:从缓存创建起算,连续使用也照样过期
default-cache-ttl-ssh1800sSSH 密钥专属闲置超时
max-cache-ttl-ssh7200sSSH 密钥专属绝对上限

三种标识符:keyid / keygrip / SHA256 指纹

同一把密钥在 GPG 生态里有三种视角,长度和用途都不同,容易混淆(gpg-agent-manager 输出里三种同时出现):

标识符长度/形式是什么出现位置
keyid16 hex,如 9FCAF6A748F642BAGPG 指纹(40 hex)的末尾 16 位(截断短 ID)gpg --list-secret-keys 的 sec 行、--keyid-format
keygrip40 hex,如 D2BA4B39…B3803SHA-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 指纹校验

要点:

坑: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

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。

两个必要条件(缺一无效):

口令过期快速失败后,让用户在自己的终端用正确 GPG_TTY=$(tty) 解锁一次(如 pass show sudo)刷新缓存,agent 重试即可。

私钥存储与保护

私钥无口令 ≠ 数据明文:pass 里保存的 sudo 密码始终是公钥加密的密文(sudo.gpg),与私钥有没有口令无关。私钥无口令只是 .key 文件本身明文(仍在 ~/.gnupg 0700 权限保护下),少了最后一道防线:

泄露组合私钥有口令私钥无口令
仅 sudo.gpg解不开解不开
sudo.gpg + .key解不开(无口令)密码直接泄露

给 GPG 主密钥设口令(gpg --change-passphrase 设的即是 .key 文件保护口令,也是以后使用时要输的,只有一个口令)后由 gpg-agent 保管:加密落盘 + 解锁状态内存缓存。架构上 gpg 客户端不直接碰私钥,解密/签名必须经 gpg-agent 中转,所以 gpg-agent 始终必需;有口令时它是"守卫",无口令时只是"透明通道"。

安全模型

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>     # 删除旧条目

常见问题

问答记录

Q1: 脚本交互输入口令后卡死(输入了口令但无任何响应)?

终端残留 pinentry-curses 遗留的 raw 模式(stty -a 显示 -icanon)导致:按 Enter 只发送 \r,而 bash read 等待 \n → 永远等不到换行。典型来源:pinentry-curses 弹错终端(GPG_TTY 未导出)或被 killall pinentry-curses 强杀后未恢复终端属性。

Q2: gpg-preset-passphrase 支持从 stdin 读口令吗?为什么脚本还要先 read 一遍?

支持,脚本正是在用它:printf '%s\n' "$passphrase" | gpg-preset-passphrase -c "$grip"。但 read 不能省:

  1. 一次输入要复用多次:主密钥+子密钥 = 多次 preset 调用,每次调用消耗一份 stdin;直接交终端 stdin 给第一次调用,后续 grip 就没口令可读(阻塞)
  2. 静默输入: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+Dexit 0
口令 + 回车 + Ctrl+Dexit 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>            # 验证完清理

常见误区(均已被实测推翻):

重要注意: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。

解法(按优先级):

  1. 拉长 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 模板,否则重跑会被覆盖
  2. 需要立刻重置计时时用 gpg-manager.sh --forget:脚本先打印每把密钥当前缓存状态(KEYINFO 第 7 字段:1=已缓存,-=未缓存),所有 GPG/SSH 密钥口令一致时只输入一次,逐把询问 预设? [y/N];带 --forget 时预设前先 CLEAR_PASSPHRASE 删条目,使 max-cache-ttl 重新计时
  3. 也可在 agent 场景为命令前缀 GPG_TTY=/dev/nonexistent 让 pinentry 快速失败而非画框(见上文「Agent 上下文」节),但那是"不弹"不是"能过",治标不治本

Q10: gpg-preset-passphrase 的 preset 与 forget 对 TTL 的影响?

操作default-cache-ttlmax-cache-ttl
preset(条目已存在)✅ 重置闲置计时器❌ 不刷新(created 锚定)
forget → preset(条目重建)✅ 重新计时✅ 重新计时

--forget(CLEAR_PASSPHRASE 删除条目)后再 preset,gpg-agent 视为全新缓存条目,两个计时器都从零开始。注意本环境 gpg-preset-passphrase -f 实测报 IPC 无查询回调,用 gpg-connect-agent "CLEAR_PASSPHRASE <grip>" /bye 更可靠。