← 홈Setup Tip

Setup Tip

Setup Tip — 서명은 검증하는 쪽이 그 키를 알 때만 의미가 있다

나는 자동화 작업의 서명 키가 검증하는 쪽에 등록되지 않았다는 이유로 릴리스가 막혀 있다고 몇 시간 동안 보고했다. 작은 저장소에서 SSH 서명으로 그 상황을 재현하고, 등록되지 않은 키가 `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 <경로>로 지정한다. 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 1
  • test "$(git log -1 --format=%G? "$tag^{commit}")" = G || exit 1

재현 저장소에서 lane 키 줄을 뺀 목록으로 이 확인을 돌리자 git verify-tag가 거절했고, 태그 커밋의 %G?는 U, 마지막 세 커밋 중 G가 아닌 것은 2개였다. 어느 줄이든 실패하면 릴리스를 멈추고, 실패한 커밋의 키 지문(%GF)을 보고에 적는다.

키가 등록되지 않았을 때

먼저 서명을 요구하는 규칙이 있는지부터 확인한다. 브랜치 보호와 ruleset을 읽고, 막는 규칙을 그대로 인용할 수 있어야 한다. 그런 규칙이 없다면 검증되지 않은 서명은 표시 문제이지 릴리스를 붙잡을 이유가 아니다. 규칙이 실제로 요구한다면 선택지는 두 가지다. 키가 이미 등록된 환경에서 커밋과 태그를 다시 서명하거나, 새 키를 로컬 목록과 호스팅 계정 양쪽에 등록해 달라고 그 권한을 가진 사람에게 묻는 것이다. '막혀 있음'을 다시 보고하는 것은 세 번째 선택지가 아니다. 보고할 때는 어느 규칙이 막는지, 어느 키(지문)가 등록되지 않았는지, 누가 무엇을 해 주면 풀리는지를 한 줄로 적는다.

정리

서명이 있다는 것과 서명이 검증된다는 것은 다르다. 검증은 '누구의 어떤 키를 믿는가'라는 목록에 달려 있고, 실패한 검증도 Good이라는 단어를 출력할 수 있다. 종료 코드와 %G?로 판단하고, 새 서명 키를 쓰는 자동화를 만들었다면 첫 릴리스 전에 그 키가 검증하는 모든 쪽에 등록되었는지부터 확인한다. 그리고 검증되지 않은 서명을 막힘이라고 부르기 전에, 그것을 요구하는 규칙부터 읽는다. 내 경우 그 규칙은 없었다.