IT/DevSecOps

cosign 키리스 서명, 안에서는 무슨 일이 일어나는가

gfrog 2026. 9. 6. 03:19

컨테이너 이미지 서명을 처음 도입할 때 가장 골치 아팠던 건 키 관리였다. 개인 서명키를 KMS에 넣을지, HSM에 넣을지, CI 러너마다 어떻게 주입할지, 로테이션은 어떻게 할지. 팀 규모가 작으면 그냥 GPG 키 하나로 시작하지만, 사람이 바뀌고 파이프라인이 늘어나면 순식간에 통제 불능이 된다.

sigstore가 등장한 이후 이 판이 뒤집혔다. 우리 팀도 올해 들어 대부분의 이미지 서명을 cosign 키리스 모드로 옮겼다. cosign sign --yes ghcr.io/org/app@sha256:... 한 줄이면 끝난다. 서명키를 어디서 가져오는지 아무도 신경 쓰지 않는다. 근데 나는 "키가 없는데 어떻게 서명이 되지?"라는 질문에 자신 있게 답을 못 하고 있었다. 그래서 문서를 파고 트래픽을 뜯어봤다. 사실 내부적으로는 꽤 재미있는 일이 벌어지고 있다.

키가 없는 게 아니라 10분짜리 키가 있다

키리스라는 이름은 오해를 부른다. 실제로는 서명 시점에 임시 키페어를 만들고 서명 직후에 개인키를 폐기한다. 즉 키는 있는데 수명이 매우 짧다.

흐름을 따라가 보면 이렇다. cosign이 실행되면 먼저 로컬에서 ECDSA P-256 키페어를 하나 생성한다. 이건 그냥 메모리에 잠깐 존재하는 값이다. 다음으로 OIDC 인증을 시작한다. 사람이 로컬에서 실행하는 경우엔 브라우저가 열리면서 구글이나 깃허브로 로그인하고, GitHub Actions 같은 CI 환경에서는 이미 발급돼 있는 워크플로우 OIDC 토큰(ACTIONS_ID_TOKEN_REQUEST_URL 환경변수)을 그대로 사용한다.

여기서 받은 OIDC 토큰과 아까 만든 공개키를 함께 Fulcio라는 인증기관에 보낸다. Fulcio는 sigstore 프로젝트가 운영하는 CA다. 토큰의 서명을 검증하고, 그 안에 담긴 sub(예: https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main)를 SAN(subjectAltName)에 박은 X.509 인증서를 발급해준다. 유효기간은 10분. 이 인증서가 "이 공개키의 주인은 그 시점의 그 워크플로우가 맞다"를 보증해준다.

이제 cosign은 이 임시 개인키로 이미지 매니페스트의 digest를 서명한다. 서명, 인증서, 그리고 서명 이벤트 메타데이터를 Rekor라는 투명성 로그에 append-only로 기록한다. Rekor는 서명 요청을 받아 Merkle tree에 항목을 추가하고 SET(Signed Entry Timestamp)를 돌려준다. 서명이 언제 발생했는지에 대한 부인 방지 근거가 여기서 생긴다.

마지막으로 서명 페이로드, 인증서, Rekor 항목의 ID와 SET을 묶어서 OCI 레지스트리에 태그 하나로 푸시한다. 태그 형식은 sha256-<digest>.sig다. 이때쯤이면 아까 생성했던 개인키는 이미 프로세스 메모리에서 지워졌다. 남은 건 공개 검증 가능한 흔적뿐이다.

왜 굳이 이렇게 복잡한가

처음 봤을 때 나는 "그냥 KMS에 키 하나 두는 게 낫지 않나"라고 생각했다. 근데 몇 가지 시나리오를 굴려보고 마음이 바뀌었다.

첫째, 키 유출 위험이 사실상 사라진다. 서명키가 10분만 살아 있으니 훔쳐도 쓸 데가 없다. CI 러너가 침해돼도 마찬가지다. 반면 장기 키는 한 번 새면 그동안 서명된 모든 게 의심스러워진다.

둘째, 신원과 서명이 직접 묶인다. 기존 방식은 "이 키가 서명했다"까지만 알려주고 그 키의 소유자는 별도 관리대장을 봐야 한다. 키리스는 인증서 안에 "이 서명은 X 리포지토리의 Y 워크플로우가 발급했다"가 그대로 박혀 있다. 정책 엔진에서 cosign verify --certificate-identity-regexp='https://github.com/myorg/.*' --certificate-oidc-issuer='https://token.actions.githubusercontent.com'처럼 신원 기준으로 걸러낼 수 있다.

셋째, 감사가 쉼다. Rekor는 공개 투명성 로그다. 누구든 조회할 수 있고, 로그의 무결성은 Merkle proof로 검증된다. 내부적으로 "지난주에 릴리스된 이미지들이 정말 우리 CI에서 나왔는지" 확인하려고 Rekor를 뒤져본 적이 있는데, rekor-cli search --sha <digest>로 몇 초 만에 답이 나왔다.

오프라인 검증은 어떻게 되나

이 부분이 좀 오해가 많다. 검증할 때마다 Fulcio나 Rekor에 붙어야 하는 건 아니다. cosign 검증은 두 가지 정보를 사용한다. 하나는 sigstore의 TUF(The Update Framework) 루트에서 가져온 신뢰 루트(Fulcio CA 인증서, Rekor 공개키 등)다. 다른 하나는 이미지 옆에 붙어 있는 서명 번들(인증서 + 서명 + 서명 시점의 Rekor 응답)이다.

TUF 루트만 로컬에 미리 갱신해두면 검증 자체는 오프라인으로 돌아간다. 인증서가 Fulcio 루트로 체인이 되는지, 인증서 유효기간이 서명 시점을 포함하는지(SET의 시각과 비교), 매니페스트 서명이 인증서의 공개키로 검증되는지. 이 세 가지를 로컬에서 확인한다.

에어갭 환경에서 이걸 돌리려면 두 가지가 필요하다. cosign initialize --mirror <내부 미러> --root <TUF 루트 JSON>으로 TUF를 내부화하고, 검증 시점에 --offline 플래그를 붙이면 된다. 우리 팀은 사내 admission controller에서 이 방식으로 쓴다. 프로덕션 클러스터의 kube-apiserver가 매번 sigstore.dev로 나가는 건 부담이라서.

실제로 붙일 때 부딪히는 것들

문서만 보면 쉬워 보이는데 막상 도입하면 자잘한 이슈가 있다. 우리가 겪은 것들 몇 가지.

Rekor의 write rate limit. 공개 인스턴스는 IP당 초당 몇 건 수준의 제한이 있다. 매트릭스 빌드로 이미지 수십 개를 병렬 서명하다가 429를 맞고 릴리스가 30분 밀린 적이 있다. 결국 이미지 여러 개를 하나의 서명 워크플로우로 묶어 순차 처리하도록 바꿨다.

OIDC subject 문자열의 미묘한 차이. 같은 GitHub 리포지토리라도 태그 푸시로 실행됐을 때, PR에서 실행됐을 때, reusable workflow에서 실행됐을 때 subject 문자열이 다 다르다. --certificate-identity로 정확히 매칭하려다가 자꾸 실패해서 결국 --certificate-identity-regexp로 도망갔다. 이게 편하긴 한데 너무 넓게 잡으면 정책 의미가 옅어진다. 우리는 ^https://github\.com/org/repo/\.github/workflows/(release|hotfix)\.yml@refs/(heads/main|tags/v.*)$처럼 워크플로우와 브랜치를 모두 고정한다.

Kyverno나 policy-controller에서 검증할 때 인증서 identity 재검증을 잊는 실수도 흔하다. --certificate-identity 없이 그냥 "서명이 있는지"만 검사하면 sigstore로 서명된 어떤 이미지든 통과된다. 공격자가 자기 GitHub 계정으로 정상 서명한 악성 이미지를 태그만 바꿔 밀어넣을 수 있다는 얘기다. 실제로 supply chain 침해 시나리오에서 자주 지적되는 지점이다.

남은 궁금증

한 가지 아직 정리가 덜 된 게, private OIDC provider를 어디까지 신뢰할 것인가다. 사내 IdP를 Fulcio에 신뢰 발급자로 등록해서 사내 신원 기반으로 서명하는 방식도 가능한데(private sigstore 배포), 그러면 결국 IdP가 뚫리면 서명도 뚫린다. 공개 sigstore를 쓸 때의 "제3자 투명성 로그"라는 방어층이 옅어진다. 우리 팀은 프로덕션 이미지는 GitHub OIDC + 공개 sigstore, 사내 실험용 이미지는 별도 private 인스턴스로 분리해서 쓰는 중인데 이게 최선인지는 아직 확신이 없다.

여기서 더 들어가면 SLSA provenance 얘기랑 이어지는데, 그건 다음에 정리해서 써보려고 한다. attestation 포맷(in-toto)이랑 cosign attest 흐름이 서명과 어떻게 겹치고 어떻게 다른지, 이것도 처음엔 헷갈렸다.