블로그보안

HMAC으로 웹훅 서명 검증하기

웹훅 엔드포인트에 HMAC 인증이 왜 필요한지, 표준 제공자 서명 패턴, 원시 본문·상수 시간 함정, 그리고 HMAC이 보호하지 않는 것을 정리합니다.

웹훅 엔드포인트는 공개 URL입니다. Stripe, GitHub, 또는 당신의 결제 처리사가 거기로 이벤트를 POST하지만, 경로를 알아낸 누구든 그럴 수 있습니다. 그리고 경로는 샙니다. 브라우저 네트워크 탭, 프록시 로그, 에러 추적기, 그리고 가끔 붙여 넣어진 curl 명령에 놓입니다. 도착한 JSON이 무엇이든 그에 따라 행동하는 엔드포인트는, 결국 위조된 payment.succeeded 이벤트에 따라 행동하게 될 엔드포인트입니다. 인증은 선택이 아닙니다. 웹훅 서명이 존재하는 이유 전부입니다.

단순 해시는 충분하지 않은 이유

순진한 방어는 요청 본문을 해싱해 비교하는 것입니다. 아무 일도 하지 않습니다. SHA-256 같은 해시는 공개 함수입니다. 비밀도, 키도 없습니다. 본문을 위조하는 공격자는 당신이 하는 것과 똑같이 그 SHA-256을 계산해 붙이고, 당신의 검사를 통과합니다. 비대칭성이 없으므로 보안도 없습니다.

필요한 것은 공유 비밀을 가진 당사자 만들 수 있는 태그입니다. 그것이 HMAC입니다.

HMAC 기초

HMAC은 키 있는 해시입니다. 메시지와 비밀 키를 받아 고정 크기 태그를 만듭니다.

  • HMAC-SHA256(secret, message) → 32바이트.

비밀이 없으면 태그를 계산할 수도 검증할 수도 없습니다. 발신자와 수신자가 같은 비밀을 가지므로 둘 다 주어진 본문에 대해 태그를 계산할 수 있고, 다른 누구도 할 수 없습니다. 본문의 한 바이트를 변조하는 공격자는 더 이상 맞지 않는 태그를 만들고, 키 없이는 올바른 것을 생성할 수 없습니다.

핵심 단어는 공유입니다. HMAC은 대칭입니다. 같은 비밀이 서명하고 검증합니다. 그것이 끝에서 다시 다룰 결과를 낳습니다.

표준 제공자 패턴

실제 웹훅 제공자들은 같은 형태로 수렴하고, 세부가 구현이 깨지는 곳이므로 정확히 이름 붙일 가치가 있습니다.

  1. 제공자는 원시 요청 본문에 대해, 보통 타임스탬프와 이어 붙여(예: timestamp + "." + body) HMAC-SHA256을 계산합니다.
  2. 결과 태그를 16진수나 base64로 인코딩해, 타임스탬프와 함께 요청 헤더에 보냅니다.
  3. 수신자는 원시 본문과 타임스탬프를 읽고, 공유 비밀로 HMAC을 재계산해, 결과를 헤더 값과 비교합니다.

Stripe와 GitHub 둘 다 HMAC-SHA256과 서명 헤더로 이 패턴을 따릅니다. Stripe는 추가로 타임스탬프를 서명해 요청을 한 시점에 묶습니다. 정확한 헤더 이름과 인코딩은 제공자마다 다르므로 문자 그대로의 문자열은 제공자 문서를 보세요. 하지만 위의 암호학적 형태는 불변입니다.

수신자 의사코드

SECRET = load_from_secrets_manager()
TOLERANCE = 300  # 초

def verify(request):
    raw_body  = request.read_raw_bytes()        # 파싱된 JSON이 아님
    sig_header = request.header("X-Signature")
    timestamp  = request.header("X-Timestamp")

    # 1. 재전송 윈도우
    if abs(now() - int(timestamp)) > TOLERANCE:
        reject("stale request")

    # 2. 정확히 서명된 페이로드로 재계산
    signed_payload = timestamp + "." + raw_body
    expected = hmac_sha256(SECRET, signed_payload)   # 16진 문자열

    # 3. 상수 시간 비교
    if not constant_time_equals(expected, sig_header):
        reject("bad signature")

    return parse_json(raw_body)   # 이제서야 파싱이 안전

원시 본문 함정

현장에서 웹훅 검증이 실패하는 가장 흔한 이유는 틀린 바이트를 서명하는 것입니다. 제공자는 자신이 보낸 정확한 바이트 시퀀스를 서명했습니다. 프레임워크가 JSON을 파싱하고 당신이 그것을 다시 직렬화해 HMAC을 계산하면, 다른 바이트를 얻습니다. 키 순서가 바뀌고, 공백이 접히고, 유니코드 이스케이프가 정규화되고, 끝의 줄바꿈이 사라집니다. 다시 직렬화한 JSON에 대한 HMAC은 원본에 대한 HMAC과 맞지 않습니다.

JSON 미들웨어가 건드리기 전에 원시 본문을 포착하세요. Express에서는 웹훅 라우트에 원시 본문 파서를, 적극적으로 파싱하는 프레임워크에서는 라우트별 옵트아웃이 종종 필요합니다. 그 정확한 바이트에 대해 검증한 다음, 파싱하세요.

재전송 보호

유효한 서명 요청은 공격자가 포착해 다시 보내도 여전히 유효합니다. HMAC만으로는 "이 본문이 비밀을 가진 누군가에게서 왔다"고 말할 뿐, 언제몇 번인지는 아무것도 말하지 않습니다. 재전송 보호가 없으면, 포착된 refund.created는 비밀이 교체될 때까지 재전송될 수 있습니다.

표준 완화책은 당신이 이미 서명하는 타임스탬프입니다.

  • 제공자는 서명된 페이로드 안에 타임스탬프를 포함합니다.
  • 수신자는 타임스탬프가 허용 윈도우(5분이 흔한 기본값) 밖인 요청을 거부합니다.

윈도우는 진짜 트레이드오프입니다. 너무 빡빡하면 정당한 재시도나 제공자와 수신자 사이 시계 차이가 거짓 거부를 일으키고, 너무 느슨하면 재전송 윈도우가 넓어집니다. 더 강한 보장을 원하면 nonce나 제공자의 이벤트 ID를 기록해 중복을 거부하세요. 다만 그것은 보관 기간과 일관성 문제를 따로 가진 저장소를 요구합니다. 타임스탬프 윈도우는 실용적 하한이고, 당신 쪽의 멱등성(같은 이벤트 ID를 최대 한 번 처리)이 지속 가능한 답입니다.

상수 시간 비교

기대 태그와 수신 태그를 가졌을 때, ==로 비교하는 것은 취약점입니다. 일반 문자열 비교는 맞지 않는 바이트를 찾는 즉시 반환합니다. 그것은 첫 바이트가 맞는 틀린 추측이 즉시 실패하는 것보다 측정 가능하게 더 오래 걸린다는 뜻입니다. 당신의 응답 시간을 잴 수 있는 공격자는 올바른 태그를 한 바이트씩 복구할 수 있습니다. 전형적 타이밍 공격입니다.

첫 차이가 어디든 상관없이 항상 전체 길이를 검사하는 상수 시간 비교를 쓰세요.

  • 파이썬: hmac.compare_digest(a, b)
  • Node.js: crypto.timingSafeEqual(bufA, bufB)(길이가 같은 버퍼)
  • Go: hmac.Equal(macA, macB)

이들은 바로 이 목적으로 존재합니다. 비밀이나 유도된 태그를 비교할 때마다 == 대신 그것으로 손을 뻗으세요.

HMAC이 주지 않는 것

HMAC 검증은 발신자를 인증하고 무결성을 보호합니다. 사람들이 그것이 준다고 가정하는 다음 몇 가지는 주지 않습니다.

  • 기밀성. 본문은 연결이 암호화되지 않는 한 평문으로 이동합니다. HMAC은 아무것도 숨기지 않습니다. TLS를 쓰고, 웹훅을 HTTPS로만 받으세요.
  • 부인 방지. 비밀이 공유되므로, 유효한 태그는 비밀을 가진 누군가가 그것을 만들었다는 것만 증명하며, 여기에는 수신자인 당신도 포함됩니다. 제공자가, 당신 자신의 서비스가 아니라, 주어진 요청을 생성했다는 것을 제삼자에게 증명할 수 없습니다. 대칭 인증은 상호적이지 귀속 가능하지 않습니다.

부인 방지에는 비대칭 서명이 필요합니다. 서명자가 개인 키를 쥐고, 검증자는 공개 키만 쥐며, 유효한 서명은 개인 키 보유자에게서만 나올 수 있습니다. 그것이 RS256, ES256, EdDSA 뒤의 모델입니다. 대비는 JWT 서명 알고리즘을 보세요. 그래도 대부분의 웹훅 제공자는 일부러 대칭 HMAC을 고릅니다. 더 빠르고, 당사자가 정확히 둘일 때 비밀 분배 문제가 사소하며, 서버 대 서버 이벤트 피드에서 부인 방지가 목표인 경우가 드물기 때문입니다.

해시 대 키 있는 해시 구분이 아직 흐릿하다면, 해싱·암호화·인코딩이 왜 맨 해시는 비밀을 지니지 않고 키 있는 해시는 지니는지 풀어 놓습니다.

요약 체크리스트

올바른 웹훅 검증기는 이렇습니다.

  • 원시 본문 바이트를 읽습니다. 결코 다시 직렬화한 JSON이 아닙니다.
  • 제공자가 서명한 정확한 페이로드에 대해, 타임스탬프를 포함해 HMAC-SHA256(secret, signed_payload)을 재계산합니다.
  • 상수 시간 함수로 태그를 비교합니다.
  • 타임스탬프 허용 윈도우 밖 요청을 거부하고, 이벤트 처리를 멱등으로 다룹니다.
  • HTTPS로 돌고, 비밀을 시크릿 매니저의 다른 자격 증명처럼 다루며, 교체 경로를 미리 계획합니다.

통합을 디버깅하며 HMAC 태그를 손으로 계산하거나 확인할 때, 즉 생각하는 바이트를 서명하고 있는지 확인할 때, 우리 HMAC 생성기는 비밀, 메시지, 해시 함수를 받아 태그를 16진수나 base64로 돌려줍니다.