C# 웹훅 수신기 예제: ASP.NET Core와 HMAC-SHA256

ASP.NET Core로 만드는 C# 웹훅 수신기: 원본 본문을 Stream으로 바인딩하고, 타임스탬프와 바이트에 HMAC-SHA256을 계산해 각 sume-v1 항목을 고정 시간으로 비교하세요.

읽는 시간 6분Sume
전체 글

C# 웹훅 수신기는 무엇이든 본문을 파싱하기 전에 원본 요청 본문을 읽고, 바로 그 바이트에 대해 보낸 쪽의 서명을 확인한 뒤, 빠르게 2xx로 응답하는 ASP.NET Core 엔드포인트입니다. 최소 API(Minimal API)에서는 본문을 Stream으로 바인딩하고, 보낸 쪽이 서명한 대상에 대해 HMACSHA256.HashData(secret, bytes)를 계산한 뒤 CryptographicOperations.FixedTimeEquals로 비교하세요. Sume 웹훅에서 서명 대상 바이트는 <timestamp>.<raw_body>이며, 서명 헤더의 sume-v1= 항목 중 어느 것이든 일치할 수 있습니다.

.NET 관련 내용은 Microsoft Learn의 매개 변수 바인딩, 응답, HMACSHA256.HashData, FixedTimeEquals, Convert.FromHexString 페이지에서 가져왔습니다. Sume 관련 내용은 Run 웹훅 (영문), 웹훅 (영문), 웹훅 검증에서 가져왔습니다. 모두 2026-09-27에 확인했습니다. Sume의 SDK인 @sume-com/sdk는 TypeScript용이므로, .NET 수신기는 공개된 서명 방식을 직접 구현합니다.

모델을 바인딩하지 않고 본문을 Stream으로 읽는 이유는 무엇인가요?

모델 매개변수는 본문을 JSON으로 소비하므로 바이트가 사라지기 때문입니다. (Person person) 같은 최소 API 매개변수는 본문에서 JSON으로 바인딩됩니다. 요청 본문은 기본적으로 버퍼링되지 않으며, 한 번 읽으면 되감을 수 없고 다시 읽을 수도 없습니다. 파싱했다가 다시 직렬화한 본문은 검증되지 않습니다. 키 순서와 공백도 Sume가 서명한 대상의 일부이기 때문입니다. 대신 HttpRequest.Body와 같은 객체인 Stream을 바인딩하고, 핸들러 안에서 읽어 검증한 뒤, 바로 그 바이트를 역직렬화하세요.

Microsoft Learn의 매개 변수 바인딩, HMACSHA256.HashData, FixedTimeEquals, Convert.FromHexString 페이지와 Sume의 Run 웹훅 (영문), 웹훅 (영문) 기준, 2026-09-27 확인.
단계Sume 규칙.NET
원본 본문JSON을 파싱하기 전에 원본 바이트를 검증ReadAtLeastAsync로 읽는 Stream body 매개변수
헤더x-sume-webhook-timestamp, x-sume-webhook-signaturereq.Headers["x-sume-webhook-signature"]
MAC<timestamp>.<raw_body>에 대한 HMAC-SHA256HMACSHA256.HashData(key, source)
서명sume-v1=<hex>. 시크릿 교체 중에는 유효한 시크릿마다 항목 하나씩, 쉼표로 구분쉼표로 나눔. Convert.FromHexString은 hex가 아닌 입력이나 홀수 길이에 FormatException을 던짐
비교상수 시간. 일치하는 항목이 하나라도 있으면 수락CryptographicOperations.FixedTimeEquals. 걸리는 시간은 값이 아니라 길이에 좌우됨

C#에서 HMAC-SHA256은 어떻게 계산하고 확인하나요?

해시는 한 번 계산하고, 모든 항목을 확인하세요. 시크릿을 교체한 뒤 24시간 동안 Sume는 두 시크릿으로 모두 서명해 최신 것부터 보내므로, 헤더 전체를 비교하는 검증은 그 기간의 모든 전달에서 실패합니다. 재전송 허용 시간을 벗어난 타임스탬프는 먼저 거부하세요. Sume는 오 분을 무난한 기본값으로 봅니다. Microsoft 페이지에는 null 키에 대한 ArgumentNullException은 나와 있지만 빈 키에 대한 예외는 없으므로, 함수가 빈 시크릿을 직접 거부합니다. 그래서 설정되지 않은 변수 때문에 위조된 전달이 검증을 통과하는 일은 없습니다.

static bool VerifySume(byte[] body, string? ts, string? header, byte[] secret)
{
    if (secret.Length == 0 || !long.TryParse(ts, out var t)) return false;
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return false;
    var signedBytes = Encoding.UTF8.GetBytes($"{t}.").Concat(body).ToArray();
    var expected = HMACSHA256.HashData(secret, signedBytes); // over the raw bytes
    var ok = false;
    foreach (var entry in (header ?? "").Split(',')) // two entries during a rotation
    {
        var e = entry.Trim();
        if (!e.StartsWith("sume-v1=", StringComparison.Ordinal)) continue;
        byte[] got;
        try { got = Convert.FromHexString(e["sume-v1=".Length..]); }
        catch (FormatException) { continue; } // not hex: not a match
        if (CryptographicOperations.FixedTimeEquals(expected, got)) ok = true;
    }
    return ok;
}

ASP.NET Core 엔드포인트는 어떤 모습인가요?

크기 상한을 두고 Stream 본문을 읽는 Microsoft의 패턴을 그대로 따르며, 상한은 1 MiB보다 크게 잡습니다. Sume Run 웹훅의 본문 한도가 1,048,576바이트이기 때문입니다. 스트림은 핸들러 밖에서는 쓸 수 없으므로 핸들러 안에서 읽으세요. 서명 시크릿은 Sume 대시보드의 웹훅 탭에서 확인하거나 account:read가 있는 키로 GET /v1/webhooks/signing-secret을 호출해 읽고, SUME_COM_WEBHOOK_SIGNING_SECRET으로 저장하세요.

var app = WebApplication.CreateBuilder(args).Build();
var secret = Encoding.UTF8.GetBytes(
    Environment.GetEnvironmentVariable("SUME_COM_WEBHOOK_SIGNING_SECRET") ?? "");

app.MapPost("/hooks/sume", async (HttpRequest req, Stream body) =>
{
    const int max = 2 * 1024 * 1024; // above Sume's 1 MiB webhook body limit
    if (req.ContentLength is not null && req.ContentLength > max)
        return Results.StatusCode(413);
    var buffer = new byte[(int?)req.ContentLength ?? (max + 1)];
    var read = await body.ReadAtLeastAsync(buffer, buffer.Length, throwOnEndOfStream: false);
    if (read > max) return Results.StatusCode(413);
    var raw = buffer[..read];
    if (!VerifySume(raw, req.Headers["x-sume-webhook-timestamp"],
            req.Headers["x-sume-webhook-signature"], secret))
        return Results.StatusCode(401);
    await RecordOnce(raw); // your store: parse these verified bytes, dedupe, queue
    return Results.NoContent(); // 204 well inside Sume's 10 s
});
app.Run();

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

ASP.NET Core에서는 모델 매개변수나 요청에 대한 ReadFromJsonAsync처럼 무언가가 본문을 먼저 읽은 경우가 가장 흔합니다. 그러면 스트림을 다시 읽을 수 없습니다. 바이트를 검증한 다음 역직렬화하세요. 다른 시크릿이나 어긋난 시계 같은 나머지 원인은 Sume 웹훅이 도착하지 않나요?에서 차례로 점검합니다.

검증한 뒤 수신기는 무엇을 해야 하나요?

이벤트를 내구성 있게 기록하고 응답하세요. 어떤 2xx든 인정되고, 시도마다 10초가 주어지며, Sume는 최대 10회 시도하므로, 느린 작업은 204를 보내기 전에 하지 말고 큐에 넣으세요. Run 웹훅은 request_id로, 생성 Job 웹훅은 job_id로 중복을 제거하세요. 재시도와 다시 보내기(Redeliver)는 Sume 영상 실행용 서명된 웹훅에서 다룹니다.

출처

관련 글

연동 카테고리의 다른 글

연동 글 전체 보기

작성자 Sume