Rust HMAC-SHA256: axum에서 웹훅 서명 검증하기

Rust에서는 hmac·sha2 크레이트로 HMAC-SHA256을 계산합니다. axum에서는 Bytes로 받아 timestamp.body에 MAC을 적용하고 각 항목을 verify_slice로 확인하세요.

읽는 시간 6분Sume
전체 글

Rust에서 HMAC-SHA256을 계산하려면 hmac과 sha2 크레이트를 쓰세요. Hmac::<Sha256>::new_from_slice(key)를 만들고 update로 메시지를 넣은 뒤, 태그가 필요하면 finalize()를, 받은 태그를 상수 시간으로 확인하려면 verify_slice(&tag)를 호출합니다. axum에서 웹훅을 검증하려면 본문을 Json이 아니라 Bytes로 받고, 타임스탬프와 점, 그 원본 바이트에 대해 MAC을 계산한 뒤, hex 디코딩한 sume-v1= 항목 중 하나라도 verify_slice를 통과하면 전달을 수락하세요.

크레이트 관련 내용은 docs.rs의 hmac, digest::Mac, axum::extract, hex, serde_json 페이지에서, Sume 관련 내용은 Run 웹훅 (영문), 웹훅 (영문), 웹훅 검증에서 가져왔습니다. 모두 2026-09-28에 확인했습니다. Sume의 SDK는 TypeScript용이며 문서에 다른 언어로 수신기를 만드는 경우를 위한 서명 방식이 자세히 나와 있으므로, Rust에서는 검증을 직접 작성합니다. Go 버전은 Golang 웹훅 서명 검증에 있습니다.

Rust에서 HMAC-SHA256은 어떻게 계산하나요?

Cargo.toml에 hmac, sha2, hex를 추가하세요. docs.rs에는 현재 버전이 hmac 0.13.0, sha2 0.11.0으로 나와 있으며, hmac 예제는 Hmac, KeyInit, Mac을 import합니다. new_from_slice는 크기에 상관없이 어떤 키든 받습니다.

finalize()는 CtOutput을 반환하며, 이 값의 동등 비교는 상수 시간으로 실행됩니다. into_bytes()는 원시 배열을 꺼내 주는데, hmac 문서는 이를 잘못 쓰면 타이밍 공격을 허용할 수 있다고 경고합니다. 이 바이트는 서명을 보낼 때만 쓰고, 서명을 비교하는 데는 절대 쓰지 마세요.

use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;

fn main() {
    let mut mac = Hmac::<Sha256>::new_from_slice(b"my secret").expect("any key size");
    mac.update(b"1785000000.");
    mac.update(br#"{"event":"format.run.terminal"}"#);
    let signature = hex::encode(mac.finalize().into_bytes()); // lowercase hex
    println!("sume-v1={signature}");
}

Rust에서 웹훅 서명은 어떻게 검증하나요?

Sume는 <timestamp>.<raw_body>에 HMAC-SHA256으로 서명해 x-sume-webhook-signature에 sume-v1=<hex_signature>를 담아 보내고, 타임스탬프는 x-sume-webhook-timestamp에 담습니다. 서명 시크릿을 교체하는 동안에는 헤더에 유효한 시크릿마다 항목이 하나씩 쉼표로 구분되어 실리며, 어느 항목이든 일치하면 유효한 전달입니다.

verify_slice는 원시 MAC 바이트를 비교하므로 먼저 각 항목을 hex 디코딩하세요. 이 메서드는 MAC을 소비하므로 항목마다 복제해서 쓰세요. 길이가 맞지 않는 태그는 거부하고, 그다음 상수 시간 동등 비교인 ct_eq로 비교합니다. 아래 함수는 현재 코드의 Sume TypeScript 검증기처럼 모든 항목을 확인하고 빈 시크릿을 거부합니다. 각 단계는 Rust 호출 하나에 대응합니다.

Sume의 Run 웹훅 (영문)과 웹훅 (영문) 페이지, docs.rs의 hmac, digest::Mac, hex 페이지 기준, 2026-09-28 확인.
단계Sume 규칙Rust
재전송 허용 시간벗어난 타임스탬프는 거부. 오 분이 무난한 기본값SystemTime::now().duration_since(UNIX_EPOCH)
MAC<timestamp>.<raw_body>에 대한 HMAC-SHA256Hmac::<Sha256>::new_from_slice 다음에 update
항목sume-v1= 항목 중 어느 것이든 수락split(','), strip_prefix("sume-v1="), hex::decode
비교상수 시간mac.clone().verify_slice(&sig)
use axum::http::HeaderMap;
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};

fn verify_sume(headers: &HeaderMap, body: &[u8], secret: &[u8]) -> bool {
    let ts = headers.get("x-sume-webhook-timestamp").and_then(|v| v.to_str().ok());
    let sigs = headers.get("x-sume-webhook-signature").and_then(|v| v.to_str().ok());
    let (Some(ts), Some(sigs)) = (ts, sigs) else { return false };
    let Ok(ts) = ts.parse::<u64>() else { return false };
    let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
    if secret.is_empty() || now.abs_diff(ts) > 300 {
        return false; // empty key, or outside the five-minute window
    }
    let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret) else { return false };
    mac.update(format!("{ts}.").as_bytes());
    mac.update(body); // the raw bytes, never re-serialized JSON
    let mut ok = false;
    for entry in sigs.split(',') {
        let Some(hex_sig) = entry.trim().strip_prefix("sume-v1=") else { continue };
        if let Ok(sig) = hex::decode(hex_sig) {
            ok |= mac.clone().verify_slice(&sig).is_ok(); // check every entry
        }
    }
    ok
}

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

원본 요청 본문을 그대로 주는 Bytes 추출기(extractor)를 쓰세요. Json은 본문을 소비해 역직렬화하므로, 검증에 필요한 정확한 바이트가 사라집니다. 본문은 한 번만 소비할 수 있는 스트림이며, axum은 본문을 소비하는 추출기가 마지막 인수여야 한다고 요구합니다. HeaderMap을 앞에 두세요.

기본적으로 Bytes는 2MB보다 큰 본문을 받지 않습니다. Sume 실행 웹훅에는 이 한도로 충분합니다. 1 MiB를 넘는 영수증은 payload: null로 도착하고, 대신 영수증을 가져올 result_url이 함께 오기 때문입니다. 파싱은 검증을 통과한 뒤에만, 같은 바이트에서 serde_json::from_slice로 하세요.

// Same file as verify_sume, which already imports HeaderMap.
use axum::{body::Bytes, http::StatusCode, routing::post, Router};

async fn sume_webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
    let secret = std::env::var("SUME_COM_WEBHOOK_SIGNING_SECRET").unwrap_or_default();
    if !verify_sume(&headers, &body, secret.as_bytes()) {
        return StatusCode::UNAUTHORIZED; // unset secret fails too
    }
    let Ok(event) = serde_json::from_slice::<serde_json::Value>(&body) else {
        return StatusCode::BAD_REQUEST;
    };
    record_once(&event).await; // your store or queue, keyed by request_id or job_id
    StatusCode::NO_CONTENT // answer fast, work afterwards
}

fn app() -> Router {
    Router::new().route("/hooks/sume", post(sume_webhook))
}

왜 모든 서명 검증이 실패하나요?

실패는 대부분 다음 중 하나가 원인입니다.

  • 핸들러가 Json<T>를 받았거나, serde_json::Value를 다시 바이트로 바꿔 MAC을 계산한 경우. 키 순서와 공백도 서명 대상의 일부이므로, 파싱했다가 다시 직렬화한 객체는 검증되지 않습니다.
  • 헤더 전체를 비교한 경우. 시크릿 교체 중에는 헤더에 유효한 시크릿마다 항목이 하나씩 실리므로 헤더 전체 비교는 실패합니다. 쉼표로 나누고, sume-v1= 접두사가 없는 항목은 건너뛰세요.
  • hex 문자열을 그대로 verify_slice에 넘긴 경우. 이 메서드는 디코딩한 MAC 바이트를 기대하며, 길이가 맞지 않으면 오류입니다.
  • 시크릿이 맞지 않는 경우. x-sume-webhook-secret-fingerprint 헤더를 대시보드에서 시크릿 옆에 표시된 지문과 비교하세요. 나머지는 Sume 웹훅 전달 디버깅에서 다룹니다.

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

이벤트를 기록하고, Sume의 10초 시도 시간 안에 204로 응답한 뒤, 느린 작업은 큐 등을 이용해 그다음에 하세요. 재시도에도 값이 반복되므로 실행 웹훅은 request_id로, Job 웹훅은 job_id로 중복을 제거하고, 모르는 이벤트 타입에는 500 대신 204로 응답하세요. 재시도와 나머지 전달 규칙은 Sume 영상 실행용 서명된 웹훅에서 다룹니다.

출처

관련 글

연동 카테고리의 다른 글

연동 글 전체 보기

작성자 Sume