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

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 규칙 | Rust |
|---|---|---|
| 재전송 허용 시간 | 벗어난 타임스탬프는 거부. 오 분이 무난한 기본값 | SystemTime::now().duration_since(UNIX_EPOCH) |
| MAC | <timestamp>.<raw_body>에 대한 HMAC-SHA256 | Hmac::<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 영상 실행용 서명된 웹훅에서 다룹니다.
출처
- Run 웹훅 (영문)
- 웹훅 (영문)
- 웹훅 검증
- docs.rs: hmac (2026-09-28 확인)
- docs.rs: hmac::Hmac (2026-09-28 확인)
- docs.rs: digest::Mac (2026-09-28 확인)
- docs.rs: digest mac.rs 소스 (2026-09-28 확인)
- docs.rs: ctutils::CtEq (2026-09-28 확인)
- docs.rs: sha2 (2026-09-28 확인)
- docs.rs: axum::extract (2026-09-28 확인)
- docs.rs: axum::response (2026-09-28 확인)
- docs.rs: http::HeaderMap (2026-09-28 확인)
- docs.rs: hex::decode (2026-09-28 확인)
- docs.rs: hex::encode (2026-09-28 확인)
- docs.rs: serde_json::from_slice (2026-09-28 확인)
- Rust 표준 라이브러리: SystemTime (2026-09-28 확인)
관련 글
연동 카테고리의 다른 글
- Sidekiq 재시도: 백오프, 재시도 횟수, 유료 API 호출
Sidekiq은 기본적으로 실패한 작업을 약 20일간 25번 재시도합니다. 횟수를 제한하고, sidekiq_retry_in으로 retry-after만큼 기다리고, 같은 Idempotency-Key를 재사용하세요.
- Slack API 파일 업로드: files.upload 대신 세 번 호출
files.upload 지원 중단 뒤에는 files.getUploadURLExternal, upload_url에 바이트 POST, channel_id와 files.completeUploadExternal 순입니다.
- Strands Agents MCP: 에이전트를 Sume MCP 서버에 연결
MCPClient로 Strands 에이전트를 원격 MCP 서버에 연결하세요. Sume 호스팅 MCP URL과 API 키 헤더를 넣고, tool_filters로 유료 도구를 뺍니다.
- Teams Incoming Webhook 종료: Workflows로 전환
Microsoft는 Teams의 Office 365 커넥터를 May 18~22, 2026에 비활성화하기로 했습니다. 대신 Workflows 웹훅 URL을 만들고 Adaptive Card를 POST하세요.
작성자 Sume