
각 발행자의 공개 키를 고정(Pinning)했습니다. 그러자 IdP가 이를 교체(Rotate)했습니다.
요약
에이전트 위임 체인 검증 시 공개 키를 정적으로 고정(Pinning)할 때 발생하는 문제점을 다룹니다. IDP가 보안을 위해 키를 교체(Rotate)하면 고정된 키로는 검증이 실패하므로, JWKS 등을 통한 동적 키 관리의 필요성을 설명합니다.
핵심 포인트
- 정적 키 고정(Pinning)은 IDP의 키 교체 시 검증 실패를 유발함
- 서명 키는 보안 위생을 위해 주기적으로 교체되는 대상임
- Okta, Auth0 등 실제 IDP는 정기적으로 키를 로테이션함
- 안정적인 검증을 위해 JWKS와 같은 동적 키 로드 방식이 권장됨
지난번 저는 에이전트의 위임 체인(delegation chain)이 한 회사의 IDP(Identity Provider, ID 제공자)에서 다른 회사의 IDP로 넘어갈 때, 어떻게 하면 인간이 증명 가능한 상태를 유지할 수 있는지에 대해 글을 썼습니다. 검증자(Verifier)는 체인을 역순으로 따라가며 각 세그먼트를 서명한 발행자(Issuer)의 키와 대조하여 확인합니다. 저는 그 포스트를 마지막에 남겨진 단 하나의 가설에 대한 솔직한 고백과 함께 마무리했습니다.
당신은 읽을 수 있는 객체(Object) 내에서 신뢰하는 발행자를 한 번, 명시적으로 선택합니다.
그 문장은 질문 하나를 조용히 건너뛰었습니다. 키가 어떻게 그 객체 '안으로' 들어가는가 하는 점입니다. 저는 키를 정적인 PEM 문자열로 직접 수동 고정(Pinning)하여 넣었습니다. 데모에서는 잘 작동합니다. 하지만 실제 IDP(Identity Provider)가 IDP로서 수행하는 가장 평범한 일을 하는 순간, 바로 깨져버립니다.
고정(Pinning)은 문제가 없을 때까지만 괜찮습니다
신뢰 세트(Trust set)는 다음과 같은 모습이었습니다. 디스크에서 로드된, 발행자에서 공개 키로 이어지는 작은 JSON 매니페스트(Manifest)입니다:
{
"https://idp-a.local": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg...",
"https://idp-b.local": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg..."
...
검증자는 이를 읽고, 이제 두 발행자 중 어느 쪽이 서명한 토큰이든 확인할 수 있습니다. 깔끔합니다. 또한 운영자 독립적(Operator-independent)이기도 한데, 이것이 바로 Crumb의 핵심입니다. 해당 키들은 당신이 감사(Auditing) 중인 로그를 보유한 사람이 아니라, 외부 채널(Out of band)을 통해 '당신'으로부터 직접 전달된 것이기 때문입니다. 중간에 있는 누구도 자신의 신뢰성을 주장할 수 없습니다.
문제는 "정적(Static)"이라는 단어입니다. 서명 키(Signing key)는 발행자에 대한 고정된 사실이 아닙니다. 그것은 발행자가 다른 비밀 정보를 교체(Rotate)하는 것과 마찬가지로, 기본적인 위생 관리 차원에서 정해진 일정에 따라 교체하는 대상입니다. Okta는 교체합니다. Keycloak과 Auth0 역시 각자의 일정에 따라 교체합니다. 그들이 교체를 수행하면, 당신이 3주 전에 고정(Pinning)해둔 PEM은 이제 아무도 사용하지 않는 키가 되어버립니다.
그리고 이는 최악의 방식으로, 즉 조용히 실패합니다. 배포하는 순간에는 아무것도 고장 나지 않습니다. 몇 주 후 발행자(issuer)가 키를 교체(rotate)하면, 여러분의 매니페스트(manifest)가 들어본 적도 없는 키로 서명된 토큰들이 도착하기 시작하고, 그 토큰들은 모두 서명 검증(signature verification)에 실패합니다. 무언가 위조되었기 때문이 아닙니다. 여러분이 가진 현실의 복사본이 오래되어 버렸는데 아무도 알려주지 않았기 때문입니다. 해결책은 사람이 이를 알아차리고, 파일을 수정하고, 다시 배포하는 것입니다. 그것은 검증 시스템이 아닙니다. 그것은 고장 나기로 예약된 검증 시스템입니다.
키를 가져오되, 고정하지 마세요
표준은 이미 이 문제를 해결했으며, 저는 단지 중요한 부분을 사용하지 않았을 뿐입니다. OIDC 발행자(issuer)는 JWKS 엔드포인트(endpoint)에 현재의 서명 키들을 게시하며, 디스커버리 문서(discovery document)를 통해 해당 엔드포인트가 어디인지 알려줍니다. 모든 토큰은 헤더(header) 내의 kid를 통해 자신이 어떤 키로 서명되었는지 정확히 명시합니다.
따라서 검증자(verifier)는 키를 고정(pinning)하는 대신 발행자(issuer)를 고정하기 시작합니다. 여러분은 허용할 발행자를 지정합니다. 검증자는 발행자의 /.well-known/openid-configuration을 읽고, jwks_uri를 찾아, 그곳에서 키를 가져온 다음, 현재 가지고 있는 토큰의 kid와 일치하는 키를 선택합니다.
{
"https://idp-a.local": { "discovery": "https://idp-a.local" },
"https://idp-b.local": "https://idp-b.local/jwks"
...
교체(rotation)는 더 이상 검증자가 통보받아야 하는 이벤트가 아니게 됩니다. 검증자가 캐시(cache)하지 않은 kid로 서명된 토큰이 나타나면, 실패하는 대신 소스(source)는 JWKS를 정확히 한 번 다시 가져옵니다(refetch). 그리고 발행자가 방금 게시한 바로 그곳에 있는 새 키를 찾아 검증을 수행합니다. 재배포도 필요 없고, 파일 수정도 필요 없습니다. 발행자가 키를 교체했고 검증자가 이를 따라간 것입니다. 왜냐하면 검증자는 내내 발행자의 사진(snapshot)을 보고 있었던 것이 아니라, 발행자로부터 직접 읽고 있었기 때문입니다.
참고로, 한 번의 재요청(refetch)은 중요합니다. 캐싱을 해야 합니다. 그렇지 않으면 모든 토큰이 네트워크 라운드 트립(network round trip)으로 변할 것입니다. 하지만 키 교체 시 차단당할 정도로 과하게 캐싱해서도 안 됩니다. 알 수 없는 kid는 포기하기 전에 딱 한 번 다시 확인하라는 신호입니다. 이미 본 적 있는 kid는 캐시에서 즉시 가져옵니다.
가져오는 것이 신뢰하는 것은 아닙니다
이제 제가 주의해야 했던 부분은 있습니다. 왜냐하면 바로 이 지점에서 전체 전제 자체가 조용히 배신당할 수 있기 때문입니다.
Crumb은 로그를 보유한 운영자(operator)를 신뢰하지 않고도 누가 어떤 행동을 지시했는지 검증할 수 있도록 존재합니다. 만약 제가 검증자(verifier)가 네트워크를 통해 키를 가져오도록 한다면, 당연히 의문이 생깁니다. 어디서 가져와야 할까요? 이 부분을 잘못 처리하면 요청에 응답하는 누구에게 신뢰 결정을 맡기게 되는 것입니다.
키는 발급자(issuer) 자체의 엔드포인트에서 TLS를 통해 가져와야 합니다. 이것이 여러분이 정말로 idp-a.local과 통신하고 있는지, 아니면 그 이름을 사칭하는 누군가인지를 인증해 줍니다. 키는 절대 감사 대상 서버(server under audit)로부터 오지 않습니다. 그 서버는 그림에서 당신이 거짓말을 할지도 모른다고 가정했던 유일한 것입니다. 그것은 자신이 믿게 하고 싶은 원장(ledger)을 가지고 있습니다. 그리고 그 원장을 정직하다고 증명할 수 있는 키까지 공급할 수는 없습니다. 두 역할은 분리된 상태를 유지합니다.
그리고 검증자는 여전히 어떤 발급자가 신뢰할 만한지 결정합니다. 엔드포인트에서 키를 가져오는 것이 그것을 신뢰하는 것과는 다릅니다. 당신이 한 번도 언급하지 않은 발급자에게도 JWKS(JSON Web Key Set) 엔드포인트가 있습니다. 이것은 아무런 의미가 없습니다. 검증자는 오직 자신이 이미 자체적인 신뢰 집합(trust set)에 넣어둔 발급자의 키만을 사용합니다. 이는 이전에 했던 명시적 결정과 동일한 방식입니다. 바뀐 것은 단지 신뢰하는 대상이 고정된 하나의 키에서 이제는 발급자 ID로 바뀌었을 뿐이며, TLS가
첫 번째는 낯선 사람입니다. 두 번째는 자신의 것이 아닌 키를 들고 있는 신뢰할 수 있는 당사자입니다. 이 두 가지를 하나의 "안 돼(nope)"로 뭉뚱그려 버린다면, 디버거(debugger)가 실제로 원하는 유일한 정보를 버리는 꼴이 될 것입니다.
회전(Rotation)의 조용한 쌍둥이: 폐기(revocation)
회전(Rotation)은 키가 나타나는 것입니다. 이와 동일한 문제의 더 고약한 버전은 키가 사라지는 것입니다. 발행자(issuer)의 서명 키(signing key)가 탈취되면, 발행자는 해당 키를 무효화하기 위해 자신의 JWKS에서 이를 제거합니다. 이제 그 키로 서명되어 떠돌아다니는 모든 토큰은 검증을 중단해야 합니다.
kid를 통해 가져오는 것만으로는 이것을 자동으로 해결할 수 없습니다. 키를 한 번 가져와서 캐시(cache)한 검증자(verifier)는 해당 kid를 영원히 신뢰할 것입니다. 왜냐하면 이미 본 적이 있는 kid는 다시 확인하라고 요청하지 않기 때문입니다. 회전(Rotation)은 보지 못한(unseen) kid에 대한 재요청(refetch)을 통해 처리됩니다. 반면 폐기(revocation)는 이미 아주 많이 보았고 이제는 신뢰를 중단해야 하는 kid에 관한 문제입니다. 회전을 저렴하게 만들어 주었던 캐시가 바로 죽은 키를 계속 살아있게 만드는 주범입니다.
따라서 가져온 키에는 유효 기간이 있습니다. 키는 제한된 시간, 즉 TTL(Time To Live) 동안만 신뢰되며, 이 시간이 지나면 캐시는 오래된 것(stale)이 됩니다. 따라서 다시 제공되기 전에 라이브 JWKS를 통해 재확인(reconfirm) 과정을 거쳐야 합니다. 발행자가 탈취된 키를 제거하면, 재확인 과정에서 해당 키가 없는 결과가 돌아오고, 그 키로 서명된 토큰들은 실패하기 시작합니다. 이로써 폐기(revocation)는 영원히 적용되지 않는 것이 아니라 하나의 TTL 창(window) 이내에 이루어집니다.
재확인은 반드시 실패 시 차단(fail closed)되어야 하며, 이 부분이 주의 깊게 살펴볼 대목입니다. 만약 JWKS 가져오기에 실패했을 때 검증자가 대수롭지 않게 여기고 오래된 캐시를 그대로 제공한다면, 해당 가져오기를 지연시킬 수 있는 사람은 엔드포인트(endpoint)를 도달할 수 없는 상태로 유지하는 동안 탈취된 키의 수명을 연장할 수 있게 됩니다. 이것이 바로 가용성(availability)이라는 문을 통해 공격자에게 되돌려주는 폐기(revocation)의 전체 모습입니다. 따라서 재확인할 수 없는 오래된 캐시는 거부되어야 합니다. 그 대가는 발행자의 JWKS에 도달할 수 있어야 한다는 점이 이제 검증 경로(verification path)의 일부가 된다는 것이며, 이는 발행자의 사진(snapshot)이 아닌 실제 라이브 발행자를 확인하는 데 따르는 정직한 비용입니다.
과장하지 않을 부분
이제 TLS가 실제로 중대한 역할을 수행하고 있으며, 저는 이를 숨기지 않고 명확히 말해야겠습니다. "키는 발행자(issuer) 자신의 엔드포인트에서 가져온다"는 말은 여러분의 인증서 검증(certificate validation)이 유효할 때만 참입니다. 만약 일반 HTTP를 사용하는 발행자를 대상으로 이 작업을 수행하거나, 테스트가 번거롭다는 이유로 인증서 검증을 비활성화한다면, 제가 방금 설정한 신뢰 경계(trust boundary)에는 구멍이 뚫리게 됩니다. 아래 데모에서 발행자들은 localhost 상의 일반 HTTP로 실행되는데, 이는 메커니즘을 보여주기에는 적절하지만 실제 운영 환경(production)에서는 심각한 보안 결함이 될 것입니다. 정직한 주장은 "인증된 채널(authenticated channel)을 통해 발행자로부터 키를 가져온다"는 것이며, 여기서 인증된 부분은 장식이 아니라 필수 요구 사항입니다.
폐기 윈도우(revocation window)는 윈도우이지, 즉각적인 순간이 아닙니다. 발행자가 키를 무효화하더라도 해당 키는 TTL(Time To Live)이 만료될 때까지 유효한 것으로 간주됩니다. TTL을 단축하면 폐기가 더 빠르게 이루어지지만, 더 많은 페치(fetch)가 발생하는 비용이 따릅니다. 진정으로 즉각적인 버전을 구현하려면 폴링(polling)이 아닌 푸시 무효화(push invalidation) 방식이 필요하지만, 저는 아직 그것을 구축하지 않았습니다. 또한, 상태가 불안정한(flapping) 발행자에 대한 재시도(retry)나 백오프(backoff) 로직도 아직 없으며, 단순히 타임아웃이 발생하면 폐쇄형 거부(fail-closed refusal)를 수행합니다. 페치를 수행하는 검증기(verifier)는 대기 상태에 빠질 수 있으며, 실제 운영 환경의 검증기는 현재 구현된 것보다 더 유연한 대응(grace)을 필요로 합니다. 이는 실제적인 문제이며, 아직 구현되지 않았습니다.
이미 구현된 것은 실제로 문제가 되었던 부분입니다. 즉, 신뢰 세트(trust set)가 더 이상 자신이 제어할 권한이 없는 키를 동결(freeze)하지 않는다는 것입니다. 발행자의 이름을 직접 대보십시오. 그들의 키는 여전히 그들의 것이며, 실시간으로 페치되고, 교체(rotation) 과정을 따르며, 여러분이 감사(auditing) 중인 당사자로부터 단 한 번도 소싱되지 않습니다.
실행해보기
git clone https://github.com/AlexlaGuardia/crumb
python -m crumb.jwks_federation_demo
이 데모는 실제 포트에서 각각 자체적인 디스커버리 (discovery) 및 JWKS 엔드포인트를 제공하는 두 개의 ID 제공자 (Identity Provider, IdP)를 구축합니다. 아무것도 고정 (Pinning)하지 않은 검증기 (Verifier)가 두 발행자 (Issuer)의 이름을 지정하고, 실시간으로 키를 가져와(fetch) 사용자(human)까지 이어지는 위임 체인 (Delegation chain)을 검증합니다. 그다음 한 발행자가 서명 키 (Signing key)를 교체 (Rotate)하면, 동일한 검증기가 단 한 번의 재요청 (Refetch)만으로 이 교체를 따라가며 검증을 계속 유지합니다. 또한 키를 폐기 (Revoke)했을 때, TTL (Time-to-Live) 윈도우 내에서는 해당 키로 서명된 토큰이 여전히 통과되다가, 캐시가 재확인하여 키가 사라지면 거부되는 모습도 보여줍니다. 그리고 검증기가 이름을 지정한 적이 없는 발행자를 기반으로 구축된 체인은 거부됩니다. 왜냐하면 신뢰를 얻은 것은 실시간 엔드포인트 (Live endpoint)가 아니었기 때문입니다.
이것이 기반이 되는 발행자 간 스테이플링 (Cross-issuer stapling)을 포함한 Crumb의 나머지 내용은 crumb.alexlaguardia.dev에서 확인할 수 있습니다.
만약 당신이 에이전트 신원 (Agent identity) 관련 작업을 하고 있으며, 가져오기 경로 (Fetch path)가 신뢰를 잘못된 당사자에게 다시 전달하는 방법을 발견한다면, 그것이 바로 제가 지적받기를 원하는 바로 그 취약점입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기