Git 的仓库认证与提交签名是两个独立环节:前者决定能否拉取、推送代码,后者让接收者验证提交由哪把密钥签署。本文使用两对独立的 FIDO2 SSH 密钥,将这两个用途分开管理。

Git 支持使用 SSH 密钥签署 Commit 和 Tag,因此只围绕 Git 开发时,可以直接使用 YubiKey 的 FIDO2 功能,无须额外部署 GnuPG。需要遵循 OpenPGP 验签流程的项目,则应按项目要求选择 GPG 签名。

方案与适用范围

用途方案
仓库 SSH 认证独立的 ed25519-sk 凭据
Commit、Tag 签名另一对独立的 ed25519-sk 凭据
跨电脑使用驻留凭据 resident
使用时的验证verify-required,保留默认触碰要求
硬件冗余第二把 YubiKey 独立生成凭据,并提前登记公钥

本文面向 macOS、Homebrew 和 Gitea。命令中的邮箱、身份名称、域名、仓库地址均为示例。认证端需要支持 OpenSSH FIDO 密钥类型;旧 SSH 服务端可能无法接受 ed25519-sk。

FIDO2 凭据并不使用 PIV 的 9a、9c、9d 槽位。本文也不会重置 YubiKey。

一、确认软件与硬件

首先检查实际版本与程序路径:

git --version
command -v git
brew --prefix openssh
"$(brew --prefix openssh)/bin/ssh" -V
ykman info
ykman fido info

Git 的 SSH 签名需要 2.34 或更新版本。建议使用较新的、编译时启用了 FIDO 支持的 OpenSSH;仅看到版本号较新,并不能证明该构建支持硬件密钥。本文使用 Homebrew OpenSSH,显式指定程序路径,避免混用 macOS 自带程序。

ed25519-sk 需要硬件实现相应算法,YubiKey 对应固件要求为 5.2.3 或更新版本。设备不支持时应查其官方算法列表,不能仅凭“支持 FIDO2”推断支持 Ed25519。

缺少工具时可安装:

brew install openssh git ykman

已安装且版本满足要求时可以跳过安装。较旧的 macOS 可能不在当前 Homebrew 二进制包支持范围内,安装会涉及源码编译或依赖升级;这属于软件安装问题,不需要重新生成密钥来解决。

若尚未设置 FIDO2 PIN,交互设置:

ykman fido access change-pin

已有 PIN 可以跳过。FIDO2 PIN、PIV PIN 与本地文件密码是不同概念,不应混用。

二、生成独立的认证与签名凭据

先只插入主用 YubiKey。在同一个终端设置变量,将邮箱改成托管平台账户中已验证的邮箱:

CX_GIT_EMAIL='developer@example.com'
CX_KEY_ROLE=primary
CX_SSH_DIR="$(brew --prefix openssh)/bin"

mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"

CX_AUTH_KEY="$HOME/.ssh/id_ed25519_sk_git_auth_${CX_KEY_ROLE}"
CX_SIGN_KEY="$HOME/.ssh/id_ed25519_sk_git_sign_${CX_KEY_ROLE}"

下面的循环分别生成认证、签名密钥。已有同名文件会跳过;如果此前只生成了一部分文件,应先检查文件和硬件中的凭据状态,再决定如何处理。

for CX_KEY_USE in auth sign; do
  CX_KEY_FILE="$HOME/.ssh/id_ed25519_sk_git_${CX_KEY_USE}_${CX_KEY_ROLE}"

  if [ -e "$CX_KEY_FILE" ] || [ -e "$CX_KEY_FILE.pub" ]; then
    printf '跳过已有文件:%s\n' "$CX_KEY_FILE"
    continue
  fi

  "$CX_SSH_DIR/ssh-keygen" \
    -t ed25519-sk \
    -O resident \
    -O verify-required \
    -O "application=ssh:git-${CX_KEY_USE}-${CX_KEY_ROLE}" \
    -O "user=developer-${CX_KEY_ROLE}" \
    -C "git-${CX_KEY_USE}-${CX_KEY_ROLE}" \
    -f "$CX_KEY_FILE" || break
done

如果有任何生成操作失败,应先解决报错,再继续配置。若提示覆盖硬件内已有凭据,选择 n,先确认凭据用途。

各参数的含义:

参数作用
-t ed25519-sk创建硬件支持的 Ed25519 SSH 密钥
-O resident将凭据引用也保存在硬件中,方便换电脑重新取回
-O verify-required请求每次签名进行用户验证,普通 YubiKey 通常使用 PIN
-O application=ssh:…区分凭据的应用标识,必须以 ssh: 开头
-O user=…设置驻留凭据关联的用户名
-C …设置本地 SSH 密钥注释

触碰属于用户在场验证,PIN 属于用户身份验证,两者作用不同。resident 主要方便跨电脑使用,不会提高算法强度。

生成时出现的 Enter passphrase 是本地引用文件的加密密码,可以按需求设置,也可以留空。即使本地引用文件没有密码,使用硬件签名仍然需要对应 YubiKey,并遵守凭据的验证策略。

本地不带 .pub 的文件用于引用硬件凭据,不能像普通软件私钥那样脱离硬件独立签名;.pub 文件用于分发公钥。

三、配置 Git 的认证与签名

以下使用全局默认设置,会影响当前用户的 Git 仓库。已有仓库的局部配置可能覆盖全局设置;如果需要按项目区分身份,可进入仓库后使用 git config --local。

git config --global user.name 'Developer'
git config --global user.email "$CX_GIT_EMAIL"

git config --global core.sshCommand \
  "\"$CX_SSH_DIR/ssh\" -i \"$CX_AUTH_KEY\" -o IdentitiesOnly=yes"

git config --global gpg.format ssh
git config --global gpg.ssh.program "$CX_SSH_DIR/ssh-keygen"
git config --global user.signingkey "$CX_SIGN_KEY"

git config --global commit.gpgsign true
git config --global tag.gpgsign true

这里有三个容易混淆的地方:

  • core.sshCommand 用于 Git 的 SSH 传输,邮箱与提交签名不是通过它配置的。全局指定认证文件意味着 Git 默认向各 SSH 托管平台使用同一把认证密钥;需要分平台时应改用仓库局部配置或 SSH 的 Host 配置。
  • gpg.format = ssh 指定签名格式。虽然设置项包含 gpg,实际签名程序是 ssh-keygen。
  • user.signingkey 指向不带 .pub 的本地密钥引用文件,使签名流程可以直接使用它。配置为 .pub 时,需要对应密钥已存在于可用的 ssh-agent 中。

本文主流程固定指定签名密钥,不依赖自动选择,也不要求先执行 ssh-add。

四、配置本地验签

签名有效和签名者受信任是两个问题。Git 使用 allowedSignersFile 将受信任的 SSH 公钥与身份关联。

沿用现有的全局信任列表,未配置时使用 ~/.ssh/allowed_signers:

CX_ALLOWED_SIGNERS="$(git config --global --path --get gpg.ssh.allowedSignersFile)"
CX_ALLOWED_SIGNERS="${CX_ALLOWED_SIGNERS:-$HOME/.ssh/allowed_signers}"

mkdir -p "$(dirname "$CX_ALLOWED_SIGNERS")"

printf '%s namespaces="git" %s\n' \
  "$CX_GIT_EMAIL" \
  "$(cat "$CX_SIGN_KEY.pub")" >> "$CX_ALLOWED_SIGNERS"

git config --global gpg.ssh.allowedSignersFile "$CX_ALLOWED_SIGNERS"

该操作是追加,不会清空已有条目;重复运行会产生重复行,可以手动去重。公钥部分直接读取 .pub 文件,保留其真实算法标识。ed25519-sk 对应的公钥类型通常是 sk-ssh-ed25519@openssh.com,不要手工改成 ssh-ed25519。

namespaces="git" 将该信任条目限制在 Git 签名用途。这个文件只控制当前电脑的本地验签,不会自动同步到 Gitea,也不代表已经上传公钥。

五、在临时仓库验证

CX_TEST_REPO="$(mktemp -d "${TMPDIR:-/tmp}/git-sign-test.XXXXXX")"

git init "$CX_TEST_REPO"

git -C "$CX_TEST_REPO" commit --allow-empty -m "Test hardware SSH signing"
git -C "$CX_TEST_REPO" verify-commit HEAD

git -C "$CX_TEST_REPO" tag -m "Test signed tag" yubikey-test
git -C "$CX_TEST_REPO" verify-tag yubikey-test

签名时按提示完成 PIN 和触碰操作。两次验签成功时,会看到类似信息,命令退出状态应为 0:

Good "git" signature for developer@example.com ...

也可以查看最近一次提交的签名:

git -C "$CX_TEST_REPO" log -1 --show-signature

这是本地测试,不会向远端推送内容。如果此时出现 askpass 报错,先按下文排查交互环境,再重新执行失败的创建提交或标签命令。

六、在 Gitea 登记并验证公钥

进入个人设置中的 SSH/GPG 密钥管理页面。认证公钥与签名公钥均属于 SSH 公钥,应分别添加到 SSH 密钥区域。

先复制认证公钥,粘贴并保存为 git-auth-primary:

pbcopy < "$CX_AUTH_KEY.pub"

保存后,再复制签名公钥,添加为 git-sign-primary:

pbcopy < "$CX_SIGN_KEY.pub"

认证和签名分开生成有利于独立轮换,但不要因此假定托管平台为这两条公钥提供了严格的用途隔离。Gitea 的账户 SSH 公钥通常也具有仓库访问权限,具体以安装版本的实现为准。

对签名公钥执行页面提供的所有权验证。token 使用页面当次生成的值,命名空间以页面命令为准。若页面使用 -n gitea:

printf '%s' '替换为页面当前显示的token' |
  "$CX_SSH_DIR/ssh-keygen" -Y sign -n gitea -f "$CX_SIGN_KEY"

将输出中从 -----BEGIN SSH SIGNATURE----- 到 -----END SSH SIGNATURE----- 的完整签名块粘回页面。不要把命令提示、PIN 提示或触碰提示一起复制进去。

Gitea 的 token 验证与本地 allowed_signers 配置是两件事。提交中的邮箱也应对应账户已激活的邮箱;本地验签成功,不等于平台一定显示已验证。

测试仓库访问时,从仓库页面复制完整的 SSH 克隆地址:

git ls-remote 'ssh://git@git.example.com:2222/developer/project.git'

示例中的域名、SSH 端口和仓库路径均需替换。SSH 端口以克隆地址为准,不要从 HTTPS 页面端口推测。已有仓库还可检查 git remote -v,确认使用 SSH 地址。

七、配置备用 YubiKey

拔掉主钥,只插备用钥,确认其 FIDO2 PIN 已设置。在原终端执行:

CX_KEY_ROLE=backup

随后从第二节的 CX_AUTH_KEY=…、CX_SIGN_KEY=… 两行开始,重新执行生成、配置、本地测试和平台登记流程。不要重新把 CX_KEY_ROLE 设回 primary。新文件将使用 backup 后缀,对应公钥在 Gitea 中也应使用不同名称并完成签名公钥验证。

备用钥持有独立密钥,不能通过复制本地引用文件将主钥的 FIDO2 凭据克隆过去。两把钥匙的公钥都需要提前登记;两个签名公钥都加入信任列表后,本地才能识别两者的签名。

完成备用钥测试后,插回主钥。以后切换使用的钥匙,可以执行以下命令,仅修改第一行的角色:

CX_KEY_ROLE=primary
CX_SSH_DIR="$(brew --prefix openssh)/bin"
CX_AUTH_KEY="$HOME/.ssh/id_ed25519_sk_git_auth_${CX_KEY_ROLE}"
CX_SIGN_KEY="$HOME/.ssh/id_ed25519_sk_git_sign_${CX_KEY_ROLE}"

git config --global core.sshCommand \
  "\"$CX_SSH_DIR/ssh\" -i \"$CX_AUTH_KEY\" -o IdentitiesOnly=yes"

git config --global user.signingkey "$CX_SIGN_KEY"

切换仅改变后续认证和签名使用的密钥,不会修改历史提交。

八、换电脑后恢复引用

在新电脑安装支持 FIDO 的 OpenSSH,然后进入一个新建的空目录:

CX_SSH_DIR="$(brew --prefix openssh)/bin"
CX_RESTORE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ssh-resident-restore.XXXXXX")"
cd "$CX_RESTORE_DIR"
"$CX_SSH_DIR/ssh-keygen" -K

按提示输入 PIN 并操作硬件。ssh-keygen -K 会将驻留凭据对应的引用与公钥写入当前目录;它不是从 YubiKey 导出真正的私钥。

对照已登记公钥的指纹识别用途,再将对应文件移动到 ~/.ssh 并设置为期望的名称:

"$CX_SSH_DIR/ssh-keygen" -lf '实际恢复出的公钥文件.pub'

恢复后的本地文件名、注释,以及加密封装内容可能与旧电脑不同。应以公钥和指纹确认身份,而不是比较引用文件是否逐字节一致。恢复完成后还需重新配置 Git 与本地信任列表。

九、常见问题

1. ssh-askpass 不存在,或者无法输入 PIN

例如:

ssh_askpass: exec(/usr/X11R6/bin/ssh-askpass): No such file or directory
Couldn't sign message: incorrect passphrase supplied to decrypt private key

先检查签名程序、运行环境和完整报错。askpass 缺失已经表明交互输入路径存在问题,后面的通用错误信息不能单独证明 PIN 输入错误。ssh-keygen 的硬件签名 PIN 请求可能通过 askpass 完成;IDE、管道或 agent 场景也会影响提示方式。

如果缺少可用 askpass,可以在 macOS 使用下面这个最小辅助程序。它通过系统对话框读取密码,输出到调用进程,不把密码写入文件。它是本文提供的辅助实现,并非 OpenSSH 自带程序。

mkdir -p "$HOME/.local/bin"

cat > "$HOME/.local/bin/ssh-askpass-macos" <<'SH'
#!/bin/sh
case "${SSH_ASKPASS_PROMPT:-}" in
  none) exit 0 ;;
  confirm)
    exec /usr/bin/osascript - "$@" <<'APPLESCRIPT'
on run argv
    display dialog (item 1 of argv) buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel" with title "OpenSSH"
    return ""
end run
APPLESCRIPT
    ;;
esac
exec /usr/bin/osascript - "$@" <<'APPLESCRIPT'
on run argv
    set resultDialog to display dialog (item 1 of argv) default answer "" with hidden answer buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel" with title "OpenSSH"
    return text returned of resultDialog
end run
APPLESCRIPT
SH

chmod 700 "$HOME/.local/bin/ssh-askpass-macos"
export SSH_ASKPASS="$HOME/.local/bin/ssh-askpass-macos"
export SSH_ASKPASS_REQUIRE=force

重新执行失败的签名操作。force 会让相关密码输入优先通过 askpass 完成,无须为了弹窗随意设置 DISPLAY。取消对话框会导致当前操作失败,这是正常行为。

确认可用后,可以把最后两行 export 放入终端启动文件。图形界面启动的 IDE 不一定读取终端启动文件,应确认实际执行 Git 的进程能继承这两个变量;可以从已配置环境的终端启动支持命令行启动的 IDE 进行验证。若签名由 agent 执行,agent 自身的环境也要配置好,单独修改客户端变量不会更新已运行的 agent。

2. 插入钥匙后,ssh-add -L 看不到密钥

插入硬件不会自动把所有驻留凭据加入当前 agent。先确认正在使用的 agent 与程序路径:

printf '%s\n' "$SSH_AUTH_SOCK"
"$CX_SSH_DIR/ssh-add" -L

如果确实需要 agent,可以添加特定的本地引用文件:

"$CX_SSH_DIR/ssh-add" "$CX_SIGN_KEY"
"$CX_SSH_DIR/ssh-add" -L

也可以从硬件加载驻留凭据:

"$CX_SSH_DIR/ssh-add" -K

这里必须使用支持 FIDO 的 OpenSSH ssh-add。不要将它与 macOS 自带 ssh-add 的历史同名选项混淆。如果当前 agent 不支持该类型,应先解决 agent 兼容性与环境问题。添加引用不等于将真正的硬件私钥导出,也不等于免除后续 PIN 或触碰。

本文固定使用本地引用文件的主流程不依赖 ssh-add -L 中列出签名密钥。

3. defaultKeyCommand 返回 no keys

例如:

warning: gpg.ssh.defaultKeyCommand succeeded but returned no keys
error: user.signingKey needs to be set for ssh signing

gpg.ssh.defaultKeyCommand 是没有设置 user.signingkey 时的候选密钥查询机制。它不会自动发现插入的硬件。查询命令成功但没有输出公钥,同样无法完成签名。

采用本文的固定密钥方案,可以检查有效配置:

git config --show-origin --get user.signingkey
git config --show-origin --get gpg.ssh.defaultKeyCommand
git config --show-origin --get gpg.ssh.program

然后明确指定本地引用文件:

git config --global user.signingkey "$CX_SIGN_KEY"

仓库局部配置会覆盖全局配置,应根据 --show-origin 指出的来源调整。若坚持使用 agent 自动选择,应先确认 agent 中确实有密钥,再让查询脚本仅输出目标签名公钥;不要随意选择 ssh-add -L 的第一条,因为其中可能混有认证密钥、其他账户密钥或已拔出的硬件引用。

4. 本地成功,平台仍然未验证

依次检查签名公钥是否已登记、是否完成所有权验证、提交邮箱是否属于对应账户,以及服务端版本、验签配置和页面提示。固定使用真实 .pub 内容,不要手改算法名。

已有仓库若没有按预期签名,可查看配置来源:

git config --show-origin --get gpg.format
git config --show-origin --get user.signingkey
git config --show-origin --get commit.gpgsign
git config --show-origin --get gpg.ssh.allowedSignersFile

5. Rebase 或批量重签需要反复操作

签名绑定提交对象。Rebase、Amend 等修改提交对象的操作,需要为新对象重新签名,可能产生多次 PIN 或触碰交互。硬件保护限制私钥的使用与导出,但不能替代代码审查,也不能证明签署内容没有恶意。

无人值守 CI 应使用单独的机器人身份与受限凭据,不应依赖个人日常使用、需要人工触碰的 YubiKey。

参考资料