웹훅 서명, 설명합니다
마지막 업데이트:
웹훅 서명은 보내는 서비스가 각 전달에 붙이는 짧은 문자열, 즉 보낸 쪽과 받는 쪽만 공유하는 비밀을 이용해 요청의 정확한 바이트에 대해 계산한 HMAC입니다. 이 서명이 존재하는 이유는 받는 서버가 행동하기 전에 두 가지 질문에 답할 수 있게 하기 위해서입니다. 이것이 정말로 자칭하는 그 서비스에서 온 것인가, 그리고 본문이 보내진 그대로 도착했는가. 이 글은 그 검증이 실제로 어떻게 동작하는지 설명합니다. HMAC이 무엇을 덮는지, 왜 파싱된 객체가 아니라 원시 본문에 대해 확인해야 하는지, 왜 비교를 상수 시간에 수행해야 하는지, 타임스탬프가 재전송 공격을 어떻게 제한하는지, 그리고 왜 제공자마다 헤더 형식이 다른지. 실제 전달을 먼저 보고 싶다면(헤더, 서명, 원시 본문을 함께), 보내는 쪽을 웹훅 테스터로 향하게 하고 도착한 것을 살펴보세요.
비밀 URL은 왜 인증이 아닌가
가장 오래된 웹훅 방어는 추측할 수 없는 URL, 즉 보내는 서비스만 아는 길고 무작위한 경로 조각입니다. 비밀번호처럼 느껴지지만 행동은 주소와 같고, 주소는 새어 나갑니다. 그 URL은 접근 로그, 프록시 로그, 모니터링 대시보드, 버그 보고서의 스크린샷, 그리고 보내는 서비스 자체의 설정 페이지에 기록되며, 지원 엔지니어라면 누구나, 또는 그 시스템 중 하나라도 침해한 사람이라면 누구나 읽을 수 있습니다. 비밀번호는 저장 시 해시할 수 있고 사용자별로 교체할 수 있지만, URL은 사용되는 모든 곳에 그대로 복사되며, 복사본 하나하나가 상시 유효한 초대장이 됩니다.
더 깊은 문제는 엔드포인트가 요청이 도착한 뒤 그것을 어떻게 다루는가입니다. 웹훅 핸들러는 청구서를 결제 완료로 표시하고, 계정을 만들고, 라이선스를 부여하고, 데이터를 삭제합니다. 모두 위조할 가치가 있는 동작이며, 위조 요청의 비용은 공격자가 인터넷 어디에서든 보내는 HTTP POST 한 번에 불과합니다. TLS는 여기서 도움이 되지 않습니다. TLS가 인증하는 것은 접속한 상대에게 당신의 서버이지, 접속한 쪽이 누구인지에 대해서는 아무것도 말하지 않기 때문입니다. 서명은 신뢰의 근거를 “어디를 두드려야 하는지 안다”에서 “요청 자체에는 결코 나타나지 않는 비밀을 안다”로 옮기며, 이는 범주적으로 더 강한 주장입니다.
HMAC 서명이 실제로 증명하는 것
보낸 쪽과 받는 쪽은 엔드포인트를 등록할 때 생성되는 비밀을 공유합니다. 전달마다 보낸 쪽은 정의된 바이트 집합에 대해 HMAC, 즉 키가 있는 해시를 계산합니다. 보통은 원시 요청 본문이며, 앞에 타임스탬프가 붙는 경우가 많고, 전달 ID가 포함되기도 합니다. 결과는 헤더에 실려 이동합니다. 받는 쪽은 받은 바이트에 대해 자기 비밀 사본으로 HMAC을 다시 계산하고 두 값을 비교합니다. 일치가 성립하면 두 가지가 동시에 증명됩니다. 보낸 쪽이 비밀을 갖고 있었다는 것, 그리고 덮인 바이트가 보낸 쪽이 서명한 바이트와 비트 단위로 동일하다는 것. 금액 필드의 문자 하나만 바꿔도 다시 계산한 값은 완전히 달라집니다.
두 가지 한계는 분명히 말해 둘 가치가 있습니다. 첫째, 서명은 암호화가 아닙니다. 본문은 볼 수 있는 위치에 있는 누구에게나 읽히는 상태로 네트워크를 건너며(비공개로 유지하는 것은 TLS의 일입니다), 서명은 출처와 무결성만을 보증합니다. 둘째, 본문의 단순 해시는 아무것도 증명하지 못합니다. 누구나 무엇이든 해시할 수 있기 때문입니다. 해시를 메시지 인증 코드로 바꾸는 것은 비밀입니다. 이 사이트의 해시 생성기는 키 없는 해시를 계산하는데, 체크섬으로는 괜찮지만, 누구나 다시 계산할 수 있는 체크섬은 아무도 인증하지 못합니다.
원시 본문을 바이트 단위로 검증하세요
“진짜 전달인데 검증이 실패한다”의 가장 흔한 원인은 공격자가 아니라 받는 쪽 자신의 프레임워크입니다. 서명은 전송된 그대로의 본문에 대해 계산되었습니다. 이 키 순서, 이 공백, 이 이스케이프 문자, 이 숫자 형식으로. 대부분의 웹 프레임워크는 핸들러가 실행되기 전에 JSON 본문을 객체로 친절하게 파싱하고, 검증을 위해 그 객체를 다시 직렬화하면 같은 데이터가 다른 바이트로 나옵니다. 키는 재배열되고, 공백이 더해지고, 1e3은 1000으로 렌더링되고, 유니코드는 이스케이프되거나 해제됩니다. 의미상으로는 동일하지만 암호학적으로는 다르며, 완전히 진짜였던 전달의 검증이 실패합니다. Stripe의 문서는 이 규칙을 단도직입적으로 말합니다. 서명 검증에는 원시 본문이 필요하고, 프레임워크는 그것을 건드려서는 안 됩니다.
운영상의 귀결은 순서의 문제입니다. 무엇도 그것을 파싱하기 전에 원시 요청 본문을 확보하고, 그 바이트에 대해 서명을 검증한 다음에야 파싱하고 처리하세요. 본문을 다시 쓰는 프록시와 미들웨어(재인코딩, 정규화, 예쁜 출력)는 파서와 똑같이 서명을 깨뜨리며, 그래서 디버깅은 요청을 도착한 정확한 모습 그대로 보는 것에서 시작합니다. 수신 도구가 주는 것이 바로 그 시점입니다. 전달이 웹훅 테스터에 도착하면, 검사하는 것은 손대지 않은 요청, 전송된 그대로의 헤더와 본문이며, 그에 대해 서명을 정직하게 확인할 수 있습니다.
상수 시간 비교, 그리고 시계가 서명의 일부인 이유
두 HMAC 값을 비교하는 일은 자명한 단계처럼 보이지만 미묘한 누출을 숨기고 있습니다. 보통의 문자열 비교는 처음 어긋나는 문자에서 멈추므로, 추측이 일찍 틀릴수록 빨리 반환하고, 우연히 더 긴 접두사가 맞을수록 늦게 반환합니다. 수많은 요청에 걸쳐 그 시간 차이가 누적되면, 공격자는 비밀을 전혀 모른 채 한 문자씩 측정하며 유효한 서명에 다가갈 수 있습니다. 상수 시간 비교는 항상 전체 길이를 검사하고, 값이 첫 문자에서 다르든 마지막 문자에서 다르든 같은 시간이 걸립니다. 플랫폼 문서는 이 점에 대해 단호합니다. 예를 들어 GitHub의 문서는 단순 동등 연산자를 명시적으로 경고하고 crypto.timingSafeEqual 같은 상수 시간 함수를 가리킵니다. 라이브러리 함수를 쓰세요. 비교 루프를 직접 짜지 마세요.
신선함은 세 번째 속성이지만 서명만으로는 얻을 수 없습니다. 캡처된 진짜 전달을 그대로 재전송하면 완전히 유효한 서명을 달고 있습니다. 그래서 많은 방식이 타임스탬프를 서명 대상 바이트에 접어 넣고, 받는 쪽은 타임스탬프가 허용 범위를 벗어난 전달을 거부합니다. Stripe의 방식에서는 그 문서에 따르면 기본값이 5분입니다. 이 범위는 의도적인 절충입니다. 시계 어긋남과 느린 큐를 흡수할 만큼 넓고, 캡처된 요청이 빨리 낡을 만큼 좁습니다. 범위 안에서의 방어는 멱등성에 맡겨집니다. 처리한 모든 전달 ID나 이벤트 ID를 기록하고 중복을 버리세요. 어차피 필요한 일이기도 합니다. 정직한 보낸 쪽은 재시도하고, 재시도는 선의의 재전송이니까요.
제공자마다 헤더 모양이 다른 이유
메커니즘은 표준이지만 포장은 수렴한 적이 없습니다. Stripe는 t=<timestamp>,v1=<signature> 형태의 Stripe-Signature 헤더를 보내며, 여러 개의 v1 값을 한꺼번에 포함할 수 있습니다. GitHub는 X-Hub-Signature-256을 보내며, 그 값은 알고리즘 접두사가 붙은 문자열 sha256=<hex digest>입니다. 타임스탬프가 없으므로 GitHub 전달에는 내장 만료가 없고, 재전송 방어는 전적으로 전달 ID 중복 제거에 기대어 있습니다. Standard Webhooks 명세는 이 문제를 정리하려는 시도입니다. 전달 ID, 타임스탬프, 서명에 각각 별도의 헤더를 쓰고, 서명은 셋에 본문을 더한 것에 대해 계산합니다. 새로운 제공자들이 호환되지 않는 변형을 발명하지 않도록 하기 위해서입니다. 차이는 역사적 우연이지 서로 다른 보안 모델이 아닙니다. 모든 방식에서 불변인 것은 같습니다. 공유 비밀, 정의된 피복 바이트 집합, 상수 시간 비교입니다.
Stripe 형식에 v1 값이 여러 개인 것은 모든 받는 쪽이 결국 맞닥뜨리는 작업, 즉 비밀 교체를 위해서입니다. 순진하게 하면(비밀을 바꾸고, 배포하고, 기도하면) 교체는 비행 중인 모든 것을 깨뜨립니다. 옛 비밀으로 서명된 재시도와 대기 중인 전달이 갑자기 실패하기 때문입니다. 통하는 패턴은 병행 창입니다. 겹치는 기간에는 옛 비밀과 새 비밀의 서명을 모두 받아들이고, 전달이 새 비밀로 검증되는 것을 확인한 다음에야 옛 비밀을 은퇴시킵니다. 같은 창 패턴은 키 ID와 겹치는 키를 쓰는 JWT 세계에도 나타나고, 팀이 그것을 건너뛸 때의 실패 양상도 동일합니다. JWT 실수 글이 설명하는 대로입니다. 중단 없이 교체할 수 없는 비밀은 교체해야 할 때조차 교체되지 않습니다. 새어 나간 바로 그날에도요. 그래서 눈에 띄지 않는 병행 창은 사후 생각이 아니라 프로토콜의 일부입니다.