15분 토큰을 업로드 직후 태우는 이유: uv 0.12.10이 줄인 PyPI 배포 창

PyPI Trusted Publishing이 발급하는 업로드 토큰은 원래도 짧게 산다. 발급 뒤 최대 15분이면 만료된다. 그런데 패키지 업로드가 40초 만에 끝났다면 남은 시간은 왜 그대로 열어 둬야 할까. uv 0.12.10은 배포가 끝난 직후 그 토큰의 폐기를 요청한다. 업로드가 실패했을 때도 마찬가지다.

이 변화는 화려한 인증 기능이 아니다. 이미 짧은 자격 증명의 실제 사용 시간과 유효 시간을 더 가깝게 맞추는 정리 작업이다. 구현을 따라가 보면 공급망 자동화에서 성공 경로보다 실패 경로를 먼저 설계해야 하는 이유도 드러난다.

요약: 자연 만료를 기다리는 시간을 줄인다

Trusted Publishing은 CI가 보관해 둔 장기 PyPI API 토큰 대신 OpenID Connect(OIDC) 신원을 제시하는 방식이다. PyPI는 등록된 publisher와 일치하는지 확인한 뒤 해당 프로젝트에만 쓸 수 있는 단기 API 토큰을 돌려준다. 공식 문서상 이 토큰은 발급 시점부터 최대 15분 동안 유효하다.

uv 0.12.10은 이 토큰으로 모든 배포 시도를 마친 뒤 PyPI의 /_/oidc/burn-token endpoint에 폐기 요청을 보낸다. 성공적으로 wheel과 sdist를 올렸든, metadata를 읽다가 멈췄든, 서버가 업로드를 거절했든 폐기 단계까지 간다. 다만 사용자가 --token이나 UV_PUBLISH_TOKEN으로 직접 넣은 토큰은 건드리지 않는다.

CI OIDC 신원 확인에서 PyPI 단기 토큰 발급, uv 배포, burn endpoint 폐기 요청, 잔여 유효 시간 축소까지 이어지는 다섯 단계 흐름

Trusted Publishing 토큰은 최대 15분 후 자연 만료되지만 uv 0.12.10은 배포 시도 직후 폐기를 요청해 남은 노출 시간을 줄인다. HTTP 202는 접수만 뜻한다. 출처: github.com/astral-sh/uv/pull/21423.

여기서 얻는 효과는 "토큰 수명이 15분에서 0분이 됐다"가 아니다. 토큰 발급, 파일 검사, 네트워크 전송, 폐기 요청 사이에는 여전히 사용 가능한 구간이 있다. 폐기 endpoint도 실제 삭제 여부를 클라이언트에 확인해 주지 않는다. 정확한 표현은 자연 만료 전 남아 있던 불필요한 노출 시간을 줄이려 한다는 것이다.

발급과 폐기는 같은 인증 문제가 아니다

PyPI의 Warehouse 구현은 폐기 요청 본문에서 토큰 문자열을 받고, 저장된 macaroon의 식별자와 서명을 확인한다. 일반 업로드 때처럼 요청 경로와 프로젝트 권한 caveat를 다시 평가하지는 않는다. 폐기 경로는 "이 토큰으로 지금 업로드할 수 있는가"가 아니라 "서명이 맞는 이 OIDC 발급 레코드를 지워도 되는가"를 묻기 때문이다.

그 다음 경계가 중요하다. oidc_publisher와 연결된 단기 토큰만 삭제한다. 일반 사용자에게 발급된 API 토큰을 같은 endpoint로 보내도 삭제하지 않는다. 성공하면 프로젝트 보안 이력에 Short-lived API token revoked 이벤트를 남기고 서버의 macaroon 레코드를 지운다.

클라이언트 쪽 uv도 범위를 좁힌다. Trusted Publishing으로 얻은 토큰은 별도 타입으로 보관하지만, 명시적으로 제공된 credentials는 일반 자격 증명으로 남긴다. 그래서 자동 폐기 기능이 운영자가 넣은 장기 토큰까지 예상 밖으로 취소하지 않는다. 자격 증명의 출처를 문자열 모양이 아니라 획득 경로로 구분한 셈이다.

HTTP 202가 폐기 성공을 뜻하지 않는 이유

Warehouse의 burn endpoint는 유효한 OIDC 토큰을 삭제했을 때뿐 아니라 payload가 잘못됐거나, 토큰 서명이 틀렸거나, 이미 레코드가 없거나, 일반 사용자 토큰을 받았을 때도 HTTP 202와 같은 응답을 보낸다. Accepted는 요청을 받았다는 뜻일 뿐 결과 확인서가 아니다.

이 선택에는 두 가지 효과가 있다. 먼저 외부 요청자가 응답 차이만 보고 어떤 토큰이 실제로 존재하는지 시험하기 어렵다. 또 클라이언트가 202 하나를 근거로 "서버에서 확실히 폐기됐다"고 기록하는 일을 막는다. uv 코드의 주석도 성공 응답이 실제 revocation을 보장하지 않는다고 적는다.

따라서 관측은 둘로 나눠야 한다. CI 로그에서는 폐기 요청을 보냈고 HTTP 오류가 없었다는 사실을 확인한다. 실제 서버 쪽 결과는 PyPI 프로젝트의 security history와 운영 지표에서 본다. 클라이언트 로그만 저장한다면 requested라고 써야지 revoked라고 단정하면 안 된다.

배포 실패와 폐기 실패를 따로 판정한다

토큰 폐기 때문에 정상 배포를 실패로 바꾸면 재시도가 또 다른 배포를 만들 수 있다. 반대로 배포가 실패했다고 곧바로 반환하면, 이미 발급된 토큰이 자연 만료 때까지 남는다. uv 0.12.10은 이 두 결과를 분리했다.

내부 publish_files 결과를 먼저 보관하고, 그 뒤 Trusted Publishing 토큰이면 burn을 시도한 다음 원래 결과를 반환한다. 폐기 요청이 실패하면 경고를 출력하고 토큰이 자연 만료될 것이라고 알린다. 업로드 성공은 성공으로 남는다. 업로드나 metadata 검사가 실패한 경우에도 burn은 실행하지만, 최종 명령은 원래 실패를 그대로 돌려준다.

업로드 성공 또는 실패와 토큰 폐기 성공 또는 실패를 분리해 원래 배포 판정, 경고, 자연 만료, 명시적 토큰 제외 결과를 비교한 표

uv는 배포 결과를 먼저 보관한 뒤 폐기를 시도한다. 폐기 실패는 경고로 남고, 업로드의 원래 성공·실패 판정은 바뀌지 않는다. 출처: github.com/astral-sh/uv/pull/21423.

공식 통합 test는 이 순서를 더 구체적으로 고정한다. audience 조회, OIDC token 교환, 복수 파일 업로드, burn 요청이 한 번씩 이어진다. 잘못된 wheel이라 metadata 준비 단계에서 실패하는 경우에는 업로드 요청이 0회여도 burn 요청은 1회다. 반면 명시적 --token을 쓴 test에서는 burn 요청을 0회로 요구한다.

이 기능이 막지 못하는 것

토큰 폐기는 패키지 빌드 전후의 변조를 검사하지 않는다. 공격자가 CI 작업 자체를 장악해 악성 wheel을 정상 권한으로 올렸다면, 업로드 직후 토큰을 지워도 이미 게시된 파일은 그대로다. PyPI 공식 security model도 Trusted Publishing이 코드 안전성이나 저자의 신뢰성을 보증하지 않으며, artifact 무결성은 attestations 같은 별도 통제로 다뤄야 한다고 설명한다.

OIDC token도 보호 대상이다. 짧게 만료되더라도 공격자가 workflow 실행 중 OIDC token을 가로채 PyPI token으로 교환할 수 있다면 burn 이전의 창을 사용할 수 있다. GitHub Actions 권한을 최소화하고, publisher를 정확한 repository·workflow·environment에 묶고, 외부 pull request에서 release job이 실행되지 않게 하는 기본 통제가 여전히 필요하다.

또 모든 package index가 같은 endpoint를 지원한다고 볼 수 없다. uv는 production에서 registry와 같은 authority의 HTTPS /_/oidc/burn-token으로 요청하지만, 호환 registry가 mint API만 구현하고 burn을 아직 지원하지 않으면 경고 후 자연 만료로 끝난다. 이 경고는 배포 실패도, 폐기 성공도 아니다.

실전 적용: 성공 로그보다 잔여 권한을 본다

먼저 uv를 0.12.10 이상으로 올린 뒤 release workflow가 저장된 PyPI 토큰 대신 Trusted Publishing을 쓰는지 확인한다. UV_PUBLISH_TOKEN이 남아 있으면 이번 자동 폐기 대상이 아니다. migration 기간에 두 방식이 함께 설정돼 있다면 실제로 어느 credentials가 선택됐는지 로그와 workflow를 대조해야 한다.

배포 test에는 네 경우를 넣는 편이 좋다. 업로드와 폐기가 모두 성공한 경우, 업로드 성공 뒤 폐기가 실패한 경우, 업로드 실패 뒤 폐기가 성공한 경우, 명시적 토큰이라 폐기를 하지 않는 경우다. 재시도 정책은 package version과 파일 hash를 기준으로 멱등하게 만들고, 폐기 경고만 보고 같은 version을 다시 insert하지 않는다.

운영 지표도 분리한다. publish_result, burn_request_result, PyPI security history의 실제 revocation event를 각각 기록한다. HTTP 202는 요청 접수로만 센다. 배포 시간이 15분에 가까워진다면 burn 기능보다 먼저 빌드와 업로드 시간을 줄이고, 배포 직전에 토큰을 발급하도록 job 단계를 재배치해야 한다.

작은 변경이지만 방향은 명확하다. 만료 시간이 짧다는 이유로 남은 권한을 방치하지 않는다. 작업이 끝났다면 정리하고, 정리가 실패했다면 원래 작업 결과와 섞지 않고 따로 경고한다. 자동 배포에서 믿을 만한 보안 경계는 "언젠가 사라진다"가 아니라 누가, 언제, 어떤 실패 뒤에도 지우려 했는지를 검증할 수 있는 경계다.

참고 자료

댓글

이 블로그의 인기 게시물

체크아웃은 분리됐는데 Git 상태는 공유된다: Codex 0.154 worktree의 세 경계

잠근 작업이 커밋 뒤 다시 나온다: PostgreSQL 에이전트 큐의 소유권 설계

코딩 에이전트는 API 문서보다 AGENTS.md를 먼저 읽었다