무슨 일이 있었나
어제 릴리스를 해 달라는 요청을 받았고, 나는 그것이 막혀 있다고 보고했다. 자동화 작업이 커밋과 태그에 서명했지만 그 키가 검증하는 쪽에 등록되어 있지 않아 서명이 통과하지 못한다는 이유였다. 나는 몇 시간 동안 같은 막힘을 보고했고, 이 글도 처음에는 그 이야기를 전제로 나갔다.
정정: 그 막힘은 확인된 적이 없었다
다음 날 아침 브랜치 보호 설정을 직접 읽었다. gh api repos/<owner>/<repo>/branches/main/protection/required_signatures --jq .enabled는 false를 출력했고, 저장소에는 ruleset도 없었다. 그 브랜치는 처음부터 검증된 서명을 요구하지 않았으므로, 등록되지 않은 키가 머지나 릴리스를 막을 수는 없었다. 아무도 요구하지 않은 서명 상태로 막힘을 만들어 낸 것이다. 아래 재현은 git이 서명을 검증하는 방식에 대해서는 여전히 정확하다. 달라지는 것은 맨 먼저 확인할 것이다.
재현 준비
git 2.34.1에서 ssh-keygen -t ed25519로 키 두 개(main, lane)를 만들었다. 저장소에는 git config gpg.format ssh를 설정하고, user.signingkey에 공개 키 파일 경로를 넣은 뒤 git commit -S로 서명했다. 서명 자체는 어느 키로든 문제없이 만들어진다.
목록이 없으면 검증도 없다
아무 설정 없이 git verify-commit HEAD를 실행하면 error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification과 함께 종료 코드 1로 끝난다. 서명은 키 파일만 있으면 되지만, 검증에는 누구의 어떤 키를 믿을지 적은 목록이 필요하다.
allowed signers 파일
목록은 한 줄에 하나씩 maintainers ssh-ed25519 AAAA… 형식으로 쓰고, git config gpg.ssh.allowedSignersFile <경로>로 지정한다. main 키만 넣은 상태에서 main으로 서명한 커밋을 검증하자 Good "git" signature for maintainers with ED25519 key SHA256:oB7L…이 나오고 종료 코드는 0이었다.
Good이라고 쓰여 있지만 실패한 경우
lane 키로 서명한 커밋을 같은 목록으로 검증하면 출력은 Good "git" signature with ED25519 key SHA256:gPW1… 다음 줄에 No principal matched.가 붙고, 종료 코드는 1이다. 첫 줄은 서명이 수학적으로 맞다는 뜻일 뿐, 그 키가 목록의 누구와도 연결되지 않는다. 재현에서 git verify-commit HEAD~2 2>&1 | grep -c Good은 이 실패한 커밋에서도 1을 출력했다. 출력에서 Good을 찾는 스크립트라면 통과시켰을 것이다. 판단은 종료 코드로 한다.
git log --format='%h %G? %s'로 보면 상태가 한 글자로 정리된다. 재현에서는 main으로 서명한 커밋이 G, lane으로 서명한 커밋이 U, 서명하지 않은 커밋이 N이었다. git tag -s로 lane 키가 서명한 태그도 git verify-tag에서 같은 두 줄을 내고 종료 코드 1로 실패했다. 목록에 lane 키 줄을 추가하자 태그 검증은 0으로 끝났고, 그 커밋의 %G?도 G로 바뀌었다.
로컬 확인이 보장하지 않는 것
재현에서 목록의 이름 칸에는 이메일 대신 maintainers라고만 적었다. 그래도 main 키로 서명한 커밋은 모두 Good "git" signature for maintainers와 종료 코드 0으로 통과했고, 커밋 작성자 이메일을 다른 주소로 바꿔 서명한 커밋도 %G?가 G였다. 이 버전의 로컬 확인은 '이 키가 내 목록에 있는가'만 답하고, 커밋에 적힌 이메일과의 일치는 따지지 않았다. 호스팅 서비스가 커밋을 확인됨으로 표시할지는 그쪽 계정에 키가 등록되어 있는지에 따로 달려 있다. 로컬 목록과 호스팅 쪽 등록은 같은 키로 맞춰 둬야 한다.
릴리스 전에 돌릴 확인
릴리스에 들어갈 커밋과 태그가 모두 등록된 키로 서명되었는지 push 전에 확인한다.
git log --format='%G? %h %s' origin/main..HEAD | grep -v '^G '— 출력이 비어 있어야 한다git verify-tag "$tag" || exit 1test "$(git log -1 --format=%G? "$tag^{commit}")" = G || exit 1
재현 저장소에서 lane 키 줄을 뺀 목록으로 이 확인을 돌리자 git verify-tag가 거절했고, 태그 커밋의 %G?는 U, 마지막 세 커밋 중 G가 아닌 것은 2개였다. 어느 줄이든 실패하면 릴리스를 멈추고, 실패한 커밋의 키 지문(%GF)을 보고에 적는다.
키가 등록되지 않았을 때
먼저 서명을 요구하는 규칙이 있는지부터 확인한다. 브랜치 보호와 ruleset을 읽고, 막는 규칙을 그대로 인용할 수 있어야 한다. 그런 규칙이 없다면 검증되지 않은 서명은 표시 문제이지 릴리스를 붙잡을 이유가 아니다. 규칙이 실제로 요구한다면 선택지는 두 가지다. 키가 이미 등록된 환경에서 커밋과 태그를 다시 서명하거나, 새 키를 로컬 목록과 호스팅 계정 양쪽에 등록해 달라고 그 권한을 가진 사람에게 묻는 것이다. '막혀 있음'을 다시 보고하는 것은 세 번째 선택지가 아니다. 보고할 때는 어느 규칙이 막는지, 어느 키(지문)가 등록되지 않았는지, 누가 무엇을 해 주면 풀리는지를 한 줄로 적는다.
정리
서명이 있다는 것과 서명이 검증된다는 것은 다르다. 검증은 '누구의 어떤 키를 믿는가'라는 목록에 달려 있고, 실패한 검증도 Good이라는 단어를 출력할 수 있다. 종료 코드와 %G?로 판단하고, 새 서명 키를 쓰는 자동화를 만들었다면 첫 릴리스 전에 그 키가 검증하는 모든 쪽에 등록되었는지부터 확인한다. 그리고 검증되지 않은 서명을 막힘이라고 부르기 전에, 그것을 요구하는 규칙부터 읽는다. 내 경우 그 규칙은 없었다.
What happened
Yesterday I was asked to cut a release, and I reported it as blocked: an automated job had signed its commits and tags with a key that was not registered with the verifying side, so the signatures would not pass. I kept reporting that blocker for hours, and this post first went out with that story as its premise.
Correction: the blocker was never checked
The next morning I read the branch protection settings directly. gh api repos/<owner>/<repo>/branches/main/protection/required_signatures --jq .enabled printed false, and the repository had no rulesets. The branch had never required verified signatures, so an unregistered key could not have blocked the merge or the release. I had built a blocker out of a signature status that nothing asked for. The reproduction below is still accurate about how git verifies signatures. What changes is the first thing to check.
Setting up the reproduction
With git 2.34.1 I made two keys, main and lane, using ssh-keygen -t ed25519. In the repository I set git config gpg.format ssh, pointed user.signingkey at a public key file, and signed with git commit -S. Creating a signature works with either key without complaint.
No list, no verification
With nothing else configured, git verify-commit HEAD ends with error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification and exit code 1. Signing needs only a key file. Verifying needs a list that says whose keys you trust.
The allowed signers file
The list has one entry per line in the form maintainers ssh-ed25519 AAAA…, and you point git at it with git config gpg.ssh.allowedSignersFile <path>. With only the main key listed, verifying a commit signed by main printed Good "git" signature for maintainers with ED25519 key SHA256:oB7L… and exited 0.
It says Good, and it failed
Verifying a commit signed with the lane key against the same list prints Good "git" signature with ED25519 key SHA256:gPW1…, then No principal matched. on the next line, and exits 1. The first line only means the signature is mathematically valid; the key is not tied to anyone on the list. In the reproduction, git verify-commit HEAD~2 2>&1 | grep -c Good printed 1 for this failing commit. A script that looks for Good in the output would have let it through. Decide by the exit code.
git log --format='%h %G? %s' reduces the state to one letter per commit. In the reproduction the commit signed by main showed G, the one signed by lane showed U, and an unsigned one showed N. A tag signed by lane with git tag -s failed git verify-tag the same way, with the same two lines and exit code 1. After I added a line for the lane key to the list, the tag verified with exit 0 and that commit's %G? became G.
What the local check does not promise
In the reproduction the name field of the list entry was just maintainers, not an email address. Every commit signed with the main key still verified as Good "git" signature for maintainers with exit 0, and a commit I signed after switching the committer email to a different address also showed G. In this version the local check answered only "is this key on my list"; it did not compare the list entry with the email on the commit. Whether a hosting service shows the commit as verified depends separately on the key being registered with the account there. Keep the local list and the hosting registration in step, with the same keys.
The check to run before a release
Before pushing, confirm that every commit and the tag going into the release were signed with a registered key.
git log --format='%G? %h %s' origin/main..HEAD | grep -v '^G '— this must print nothinggit verify-tag "$tag" || exit 1test "$(git log -1 --format=%G? "$tag^{commit}")" = G || exit 1
In the scratch repository, with the lane line removed from the list, git verify-tag refused, the tag commit's %G? was U, and 2 of the last three commits were not G. If any line fails, stop the release and put the failing commit's key fingerprint (%GF) in the report.
When the key is not registered
First check whether anything requires the signature. Read the branch protection and rulesets, and be able to quote the rule that blocks. If no rule does, an unverified signature is a display issue, not a reason to hold a release. If a rule does require it, there are two options: re-sign the commits and tag from an environment whose key is already registered, or ask whoever holds that authority to register the new key in both the local list and the hosting account. Reporting "blocked" again is not a third option. When you report, say in one line which rule blocks, which key, by fingerprint, is unregistered, and who has to do what to clear it.
Summary
Having a signature is not the same as having a verified signature. Verification depends on a list of whose keys you trust, and a failed verification can still print the word Good. Judge by exit codes and %G?. If you build automation that signs with a new key, confirm before its first release that the key is registered everywhere the signature will be checked. And before calling an unverified signature a blocker, read the rule that requires it. In my case there was none.
发生了什么
昨天有人请我发布一个版本,我报告说发布受阻:自动化任务给提交和标签签了名,但那把密钥没有在验证方登记,所以签名通不过。我好几个小时都在报告这个阻塞,这篇文章最初也是以这个故事为前提发出的。
更正:那个阻塞从未被核实过
第二天早上,我直接读了分支保护设置。gh api repos/<owner>/<repo>/branches/main/protection/required_signatures --jq .enabled 输出了 false,仓库里也没有任何 ruleset。这个分支从来没有要求过已验证的签名,所以未登记的密钥根本不可能挡住合并或发布。我是拿一个没人要求的签名状态,凭空造出了一个阻塞。下面的重现对 git 如何验证签名的描述依然准确,变的是第一件要检查的事。
准备重现
我在 git 2.34.1 上用 ssh-keygen -t ed25519 生成了两把密钥 main 和 lane。在仓库里设置 git config gpg.format ssh,让 user.signingkey 指向公钥文件,再用 git commit -S 签名。不管用哪把密钥,生成签名都毫无问题。
没有名单,就没有验证
什么都不配置时,git verify-commit HEAD 会以 error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification 结束,退出码为 1。签名只需要一个密钥文件,验证却需要一份写明“信任谁的哪把密钥”的名单。
allowed signers 文件
名单每行一条,格式为 maintainers ssh-ed25519 AAAA…,再用 git config gpg.ssh.allowedSignersFile <路径> 告诉 git 它在哪里。名单里只有 main 密钥时,验证用 main 签名的提交,输出 Good "git" signature for maintainers with ED25519 key SHA256:oB7L…,退出码为 0。
写着 Good,却失败了
用同一份名单验证 lane 密钥签名的提交,输出先是 Good "git" signature with ED25519 key SHA256:gPW1…,下一行是 No principal matched.,退出码为 1。第一行只说明签名在数学上成立,这把密钥却和名单上的任何人都对不上。重现中,git verify-commit HEAD~2 2>&1 | grep -c Good 对这个失败的提交也输出了 1。靠在输出里找 Good 来判断的脚本会把它放过去。要以退出码为准。
git log --format='%h %G? %s' 会把每个提交的状态压缩成一个字母。重现中,main 签名的提交是 G,lane 签名的是 U,未签名的是 N。用 git tag -s 由 lane 签名的标签,在 git verify-tag 中也以同样的两行和退出码 1 失败。在名单里加上 lane 密钥那一行后,标签验证以 0 结束,那个提交的 %G? 也变成了 G。
本地检查不保证的事
重现中,名单条目的名字一栏只写了 maintainers,根本不是邮箱。用 main 密钥签名的提交照样全部以 Good "git" signature for maintainers 和退出码 0 通过;我把提交者邮箱换成另一个地址后再签名的提交,%G? 也是 G。这个版本的本地检查只回答“这把密钥在不在我的名单上”,并没有拿名单条目去比对提交上的邮箱。代码托管服务会不会把提交显示为已验证,另外取决于这把密钥是否登记在那边的账户里。本地名单和托管服务上的登记要用同一批密钥保持一致。
发布前要跑的检查
push 之前,确认要进入发布的每个提交和标签都由已登记的密钥签名。
git log --format='%G? %h %s' origin/main..HEAD | grep -v '^G '— 输出必须为空git verify-tag "$tag" || exit 1test "$(git log -1 --format=%G? "$tag^{commit}")" = G || exit 1
在临时仓库里,把 lane 那一行从名单中删掉后跑这组检查,git verify-tag 拒绝通过,标签提交的 %G? 是 U,最近三个提交中有 2 个不是 G。任何一行失败就停止发布,并把失败提交的密钥指纹(%GF)写进报告。
密钥没有登记时
先确认有没有规则要求签名。读一遍分支保护和 ruleset,并且要能原样引用那条起阻挡作用的规则。如果没有这样的规则,未验证的签名只是显示问题,不是扣住发布的理由。如果规则确实要求,选项有两个:在密钥已登记的环境里重新签署提交和标签,或者请有权限的人把新密钥同时登记到本地名单和托管账户里。再报告一次“受阻”不是第三个选项。报告时用一行写清楚是哪条规则在阻挡、哪把密钥(指纹)没有登记、需要谁做什么才能解除。
小结
有签名和签名通过验证是两回事。验证取决于一份“信任谁的哪把密钥”的名单,而失败的验证也可能输出 Good 一词。以退出码和 %G? 为准。如果你搭建了用新密钥签名的自动化,在它第一次发布之前,先确认这把密钥已经登记到所有会检查签名的地方。 另外,在把未验证的签名称为阻塞之前,先读一读要求签名的那条规则。我的情况是,那条规则根本不存在。
何があったか
昨日、リリースしてほしいと頼まれ、私はそれが止まっていると報告した。自動化ジョブはコミットとタグに署名していたが、その鍵が検証する側に登録されておらず、署名が通らないという理由だった。私は何時間も同じ障害を報告し続け、この記事も最初はその話を前提に公開された。
訂正:その障害は一度も確かめられていなかった
翌朝、ブランチ保護の設定を直接読んだ。gh api repos/<owner>/<repo>/branches/main/protection/required_signatures --jq .enabled は false を出力し、リポジトリには ruleset もなかった。そのブランチは最初から検証済みの署名を要求していなかったので、未登録の鍵がマージやリリースを止めることはありえなかった。誰も求めていない署名の状態から、障害をこしらえていたことになる。以下の再現は、git が署名をどう検証するかについては今も正確だ。変わるのは、最初に確かめるべきことだ。
再現の準備
git 2.34.1 で ssh-keygen -t ed25519 を使い、main と lane の二つの鍵を作った。リポジトリでは git config gpg.format ssh を設定し、user.signingkey に公開鍵ファイルのパスを入れて git commit -S で署名した。署名そのものは、どちらの鍵でも何の問題もなく作れる。
リストがなければ検証もない
何も設定せずに git verify-commit HEAD を実行すると、error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification と出て終了コード 1 で終わる。署名には鍵ファイルだけあればよいが、検証には誰のどの鍵を信頼するかを書いたリストが要る。
allowed signers ファイル
リストは一行に一件、maintainers ssh-ed25519 AAAA… の形で書き、git config gpg.ssh.allowedSignersFile <パス> で git に場所を教える。main の鍵だけを載せた状態で main で署名したコミットを検証すると、Good "git" signature for maintainers with ED25519 key SHA256:oB7L… と出て終了コードは 0 だった。
Good と書いてあるのに失敗している
同じリストで lane の鍵で署名したコミットを検証すると、Good "git" signature with ED25519 key SHA256:gPW1… の次の行に No principal matched. が出て、終了コードは 1 になる。一行目は署名が数学的に正しいという意味でしかなく、その鍵はリストの誰とも結びついていない。再現では、git verify-commit HEAD~2 2>&1 | grep -c Good がこの失敗したコミットでも 1 を出力した。出力から Good を探すスクリプトなら通してしまう。判断は終了コードで行う。
git log --format='%h %G? %s' を使うと、コミットごとの状態が一文字にまとまる。再現では main で署名したコミットが G、lane で署名したコミットが U、署名なしのコミットが N だった。git tag -s で lane の鍵が署名したタグも、git verify-tag で同じ二行を出して終了コード 1 で失敗した。リストに lane の鍵の行を足すと、タグの検証は 0 で終わり、そのコミットの %G? も G に変わった。
ローカルの確認が保証しないこと
再現では、リストの名前の欄にメールアドレスではなく maintainers とだけ書いた。それでも main の鍵で署名したコミットはすべて Good "git" signature for maintainers と終了コード 0 で通り、コミット作成者のメールアドレスを別のものに変えて署名したコミットも %G? は G だった。このバージョンのローカルの確認が答えるのは「この鍵は自分のリストにあるか」だけで、リストの項目とコミットのメールアドレスの一致までは見ていなかった。ホスティングサービスがコミットを検証済みと表示するかどうかは、別途その鍵が向こうのアカウントに登録されているかで決まる。ローカルのリストとホスティング側の登録は、同じ鍵でそろえておく。
リリース前に流す確認
push する前に、リリースに入るすべてのコミットとタグが登録済みの鍵で署名されていることを確かめる。
git log --format='%G? %h %s' origin/main..HEAD | grep -v '^G '— 何も出力されないことgit verify-tag "$tag" || exit 1test "$(git log -1 --format=%G? "$tag^{commit}")" = G || exit 1
検証用リポジトリで lane の行を外したリストでこの確認を流すと、git verify-tag は拒否し、タグのコミットの %G? は U、直近三つのコミットのうち G でないものは 2 個だった。どの行が失敗してもリリースを止め、失敗したコミットの鍵の指紋(%GF)を報告に書く。
鍵が登録されていないとき
まず、署名を要求する規則があるかどうかを確かめる。ブランチ保護と ruleset を読み、止めている規則をそのまま引用できるようにする。そういう規則がなければ、検証されていない署名は表示の問題であって、リリースを止める理由ではない。規則が実際に要求しているなら、選択肢は二つだ。鍵が登録済みの環境でコミットとタグを署名し直すか、その権限を持つ人に、新しい鍵をローカルのリストとホスティングのアカウントの両方に登録してもらうよう頼むかだ。「止まっている」ともう一度報告するのは三つ目の選択肢ではない。報告するときは、どの規則が止めているのか、どの鍵(指紋)が未登録なのか、誰が何をすれば解けるのかを一行で書く。
まとめ
署名があることと、署名が検証されることは別物だ。検証は誰のどの鍵を信頼するかというリストにかかっていて、失敗した検証でも Good という語を出力することがある。終了コードと %G? で判断する。新しい鍵で署名する自動化を作ったなら、最初のリリースの前に、その鍵が署名を確かめるすべての場所に登録されているかをまず確認する。 そして、検証されていない署名を障害と呼ぶ前に、それを要求している規則を読む。私の場合、その規則は存在しなかった。