Golang 웹훅 서명 검증: HMAC과 hmac.Equal

Go에서 Sume 웹훅 검증하기: io.ReadAll로 본문을 한 번 읽고, 타임스탬프와 원본 바이트에 HMAC-SHA256을 계산한 뒤, 각 sume-v1 항목을 hmac.Equal로 비교하세요.

읽는 시간 5분Sume
전체 글

Go에서 Sume 웹훅 서명을 검증하려면 io.ReadAll(http.MaxBytesReader(...))로 본문을 한 번 읽고, hmac.New(sha256.New, secret)로 <timestamp>.<raw_body>에 대한 HMAC-SHA256을 계산한 뒤, X-Sume-Webhook-Signature 헤더를 쉼표로 나눈 각 sume-v1= 항목을 hex 디코딩해 hmac.Equal로 MAC과 비교하세요. 재전송 허용 시간을 벗어난 타임스탬프는 거부하고, JSON은 검증한 바이트에서만 언마샬하세요.

Go 관련 내용은 표준 라이브러리 문서의 crypto/hmac, net/http, io, encoding/hex에서, Sume 관련 내용은 Run 웹훅 (영문), 웹훅 (영문), 웹훅 검증에서 가져왔습니다. 모두 2026-09-27에 확인했습니다. Sume에는 Go SDK가 없습니다. @sume-com/sdk는 TypeScript용이며, 문서에 다른 언어로 수신기를 만드는 경우를 위한 서명 방식이 자세히 나와 있습니다. Express 본문 한도를 포함한 Node 버전은 Express 웹훅 서명 검증에 있습니다.

검증의 각 단계에는 어떤 Go 호출을 쓰나요?

모두 표준 라이브러리에 있습니다.

Go의 crypto/hmac, net/http, encoding/hex 문서와 Sume의 Run 웹훅 (영문), 웹훅 (영문) 페이지 기준, 2026-09-27 확인.
단계Sume 규칙Go
원본 본문JSON을 파싱하기 전에 원본 바이트를 검증io.ReadAll(http.MaxBytesReader(w, r.Body, n))
헤더x-sume-webhook-timestamp와 x-sume-webhook-signature대소문자를 구분하지 않고, 헤더가 없으면 ""를 반환하는 r.Header.Get(...)
MAC<timestamp>.<raw_body>에 대한 HMAC-SHA256hmac.New(sha256.New, secret) 다음에 Write와 Sum(nil)
서명sume-v1=<hex>. 시크릿 교체 중에는 유효한 시크릿마다 항목 하나씩, 쉼표로 구분쉼표 기준으로 strings.Split, 이어서 sume-v1= 뒷부분에 hex.DecodeString
비교상수 시간, 일치하는 항목이 하나라도 있으면 수락모든 항목에 hmac.Equal(got, expected)
재전송 허용 시간벗어난 타임스탬프는 거부. 오 분이 무난한 기본값파싱한 타임스탬프를 time.Now().Unix()와 비교

crypto/hmac으로 검증기는 어떻게 작성하나요?

Go의 crypto/hmac 문서는 타이밍 부채널을 피하려면 수신 측에서 hmac.Equal로 MAC을 비교하라고 안내합니다. Equal은 타이밍 정보를 노출하지 않고 두 MAC을 비교합니다. 이 함수는 hex 문자열이 아니라 원시 MAC 바이트를 비교합니다. hex.DecodeString은 hex 문자로만 된 짝수 길이 입력을 기대하며, 루프는 디코딩에 실패한 항목을 건너뜁니다. 시크릿을 교체한 뒤 24시간 동안 Sume는 유효한 시크릿마다 항목을 하나씩 최신순으로 보내므로, 함수는 모든 항목을 확인합니다. Sume의 TypeScript 검증기처럼 빈 시크릿을 거부하므로, 설정되지 않은 환경 변수가 누구나 계산할 수 있는 빈 HMAC 키가 되지 않습니다. 필요한 패키지는 crypto/hmac, crypto/sha256, encoding/hex, strconv, strings, time입니다.

func verifySume(body []byte, ts, header string, secret []byte) bool {
    t, err := strconv.ParseInt(ts, 10, 64)
    now := time.Now().Unix()
    if len(secret) == 0 || err != nil || t < now-300 || t > now+300 { // five-minute window
        return false
    }
    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
    mac.Write(body) // the raw bytes, never re-encoded JSON
    expected := mac.Sum(nil)
    ok := false
    for _, entry := range strings.Split(header, ",") { // two entries during a rotation
        sig, found := strings.CutPrefix(strings.TrimSpace(entry), "sume-v1=")
        got, err := hex.DecodeString(sig)
        if found && err == nil && hmac.Equal(got, expected) {
            ok = true // keep checking the other entries
        }
    }
    return ok
}

net/http 핸들러에서 원본 본문은 어떻게 읽나요?

서버 요청에서 r.Body는 항상 nil이 아니고 서버가 닫아 주므로, 핸들러는 읽기만 하면 됩니다. io.ReadAll로 바이트 슬라이스에 한 번 읽으세요. 이를 http.MaxBytesReader로 감싸세요. 이 함수는 들어오는 요청 본문을 제한하기 위한 것으로, 한도를 넘으면 *MaxBytesError를 반환합니다. 상한은 Sume가 인라인으로 담는 가장 큰 실행 영수증 크기인 1 MiB보다 크게 잡아, 실행 전달이 여기에 걸리지 않게 하세요. ServeMux 패턴은 메서드까지 매칭할 수 있으므로 http.HandleFunc("POST /hooks/sume", sumeWebhook)로 등록하세요. 핸들러는 encoding/json, io, net/http, os도 import합니다.

func sumeWebhook(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 2<<20)) // 2 MiB cap
    if err != nil { // for example a *http.MaxBytesError past the cap
        http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
        return
    }
    secret := []byte(os.Getenv("SUME_COM_WEBHOOK_SIGNING_SECRET"))
    if !verifySume(body, r.Header.Get("X-Sume-Webhook-Timestamp"),
        r.Header.Get("X-Sume-Webhook-Signature"), secret) {
        http.Error(w, "bad signature", http.StatusUnauthorized)
        return
    }
    var event struct {
        Event     string `json:"event"`
        RequestID string `json:"request_id"` // dedupe key for run webhooks
        JobID     string `json:"job_id"`     // dedupe key for job webhooks
    }
    if err := json.Unmarshal(body, &event); err != nil { // the verified bytes
        http.Error(w, "bad json", http.StatusBadRequest)
        return
    }
    recordOnce(event.Event, event.RequestID, event.JobID, body) // your store or queue
    w.WriteHeader(http.StatusNoContent)
}

어떤 실수가 Go 검증기를 망가뜨리나요?

망가졌거나 안전하지 않은 검증기는 대부분 다음 중 하나가 원인입니다.

  • 검증하기 전에 json.NewDecoder로 r.Body를 디코딩하는 경우. io.ReadAll로 얻은 슬라이스를 검증한 뒤, 그 슬라이스를 그대로 json.Unmarshal하세요.
  • 다시 마샬링한 JSON을 해시하는 경우. 키 순서와 공백도 Sume가 서명한 대상의 일부이므로, 파싱했다가 다시 직렬화한 객체는 검증되지 않습니다.
  • hex 문자열을 ==로, 또는 바이트를 bytes.Equal로 비교하는 경우. Go 문서가 MAC에 권하는 대로 hmac.Equal을 쓰세요.
  • 본문 상한이 1 MiB보다 작은 경우. 그러면 큰 영수증은 읽기에 실패하고, 핸들러는 2xx가 아닌 응답을 보내며, Sume는 시도 횟수를 모두 소진할 때까지 재시도합니다.
  • 헤더나 시크릿 문제. 헤더 전체를 비교하면 시크릿 교체 기간의 모든 전달에서 실패하며, 시크릿이 맞지 않는지는 x-sume-webhook-secret-fingerprint 헤더로 드러납니다. 두 가지 모두 웹훅 전달 디버깅에서 다룹니다.

검증한 뒤 핸들러는 무엇을 해야 하나요?

이벤트를 기록하고, Sume의 10초 시도 시간 안에 204로 응답한 뒤, 느린 작업은 큐에 넘기세요. 핸들러의 구조체에는 실행과 Job의 중복 제거 키인 request_id와 job_id가 이미 들어 있습니다. 나머지 전달 규칙은 Sume 영상 실행용 서명된 웹훅에서 다루며, PHP 버전은 같은 검증을 hash_equals로 수행합니다.

출처

관련 글

연동 카테고리의 다른 글

연동 글 전체 보기

작성자 Sume