PAYATHON 2026

如何将验证 webhook 请求的 Python 代码转换为 C#

AI 小顾

结论

Python 示例的计算方式是:

HMAC-SHA256(UTF-8(signing secret), 原始 HTTP request body 字节)

C# 必须对收到的原始正文 byte[] 计算 HMAC。不要先反序列化 JSON 再重新序列化,也不要通过 StreamReader 将正文转成字符串后重新编码。计算完成后,将 X-Signature 中的十六进制字符串解码为字节,再使用 CryptographicOperations.FixedTimeEquals 进行固定时间比较。

你选择的 HMAC 算法没有问题。digest 始终不匹配,通常是因为请求正文已经被其他组件读取、读取位置不正确,或者参与计算的内容并非服务端实际发送的原始字节。

推荐实现

下面是适用于 ASP.NET Core 的实现。这个 endpoint 不应再通过 [FromBody] 绑定请求正文,否则模型绑定可能会在签名验证前读取 Body。

using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("webhooks")]
public class WebhookController : ControllerBase
{
    private readonly IConfiguration _configuration;

    public WebhookController(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    [HttpPost("lemon-squeezy")]
    public async Task<IActionResult> HandleLemonSqueezyWebhook(
        CancellationToken cancellationToken)
    {
        if (!Request.Headers.TryGetValue("X-Signature", out var headerValues) ||
            headerValues.Count != 1)
        {
            return Unauthorized("Missing or invalid X-Signature header.");
        }

        string? signatureText = headerValues[0];

        if (string.IsNullOrWhiteSpace(signatureText))
        {
            return Unauthorized("Missing X-Signature header.");
        }

        string? secret = _configuration[
            "Keys:LMWebhookSigningSecret"
        ];

        if (string.IsNullOrEmpty(secret))
        {
            throw new InvalidOperationException(
                "Webhook signing secret is not configured."
            );
        }

        /*
         * EnableBuffering 必须在读取正文之前调用。
         * 验证完成后将 Position 恢复为 0,以便后续代码继续读取正文。
         */
        Request.EnableBuffering();
        Request.Body.Position = 0;

        byte[] payload;

        using (var buffer = new MemoryStream())
        {
            await Request.Body.CopyToAsync(buffer, cancellationToken);
            payload = buffer.ToArray();
        }

        Request.Body.Position = 0;

        byte[] suppliedSignature;

        try
        {
            // X-Signature 是 HMAC digest 的十六进制表示。
            suppliedSignature = Convert.FromHexString(signatureText);
        }
        catch (FormatException)
        {
            return Unauthorized("Invalid X-Signature format.");
        }

        byte[] computedSignature;

        using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)))
        {
            computedSignature = hmac.ComputeHash(payload);
        }

        bool isValid =
            suppliedSignature.Length == computedSignature.Length &&
            CryptographicOperations.FixedTimeEquals(
                computedSignature,
                suppliedSignature
            );

        if (!isValid)
        {
            return Unauthorized("Invalid signature.");
        }

        // 只有签名验证通过后,才能解析和处理 payload。
        string requestBody = Encoding.UTF8.GetString(payload);

        // 在这里反序列化并处理 webhook。
        // var webhook = JsonSerializer.Deserialize<WebhookPayload>(requestBody);

        return Ok();
    }
}

它与 Python 代码的对应关系如下。

Python 中的:

secret.encode()

对应 C# 中的:

Encoding.UTF8.GetBytes(secret)

Python 中的:

request.body

对应 ASP.NET Core 中未经修改的原始正文:

byte[] payload

Python 中的:

hmac.compare_digest(digest, signature)

对应:

CryptographicOperations.FixedTimeEquals(
    computedSignature,
    suppliedSignature
)

为什么不建议使用 StreamReader

原代码先将正文读取为字符串:

string requestBody =
    await new StreamReader(Request.Body).ReadToEndAsync();

然后再把字符串编码成字节:

Encoding.UTF8.GetBytes(requestBody)

这样无法保证最终得到的字节与原始请求完全一致。StreamReader 可能执行编码检测或处理字节顺序标记。更常见的情况是,Body 已经被模型绑定、中间件或日志组件读取,导致此处只能读到空内容或剩余的部分内容。

HMAC 对每个字节都敏感。比如,下面两段 JSON 的语义相同:

{"id":123,"name":"test"}
{
  "id": 123,
  "name": "test"
}

但它们的 HMAC 完全不同。因此,验证前不能先反序列化再重新序列化,也不能改动空格、换行或属性顺序。

如果必须获得十六进制 digest

调试时,可以把计算结果转换成与 Python hexdigest() 相同格式的十六进制字符串:

string digest = Convert
    .ToHexString(computedSignature)
    .ToLowerInvariant();

如果目标框架不支持 Convert.ToHexString,可以改用:

string digest = BitConverter
    .ToString(computedSignature)
    .Replace("-", string.Empty)
    .ToLowerInvariant();

正式验证时仍建议比较解码后的字节,不要使用普通字符串比较:

digest.Equals(signatureText)

普通字符串比较不具备签名验证所需的固定时间语义。

仍然不匹配时重点检查

  1. 尽早启用缓冲

    Request.EnableBuffering() 必须在任何组件读取正文之前调用。如果正文已经被读取,之后再启用缓冲通常无法恢复已消耗的内容。必要时,将验证逻辑移到请求管道中更靠前的中间件里。

  2. 不要同时使用 [FromBody]

    如果 action 参数通过 [FromBody] 接收对象,ASP.NET Core 模型绑定可能会先读取正文。应直接读取原始 Body,签名验证成功后再手动反序列化。

  3. 检查 signing secret

    本地 secret 必须与提供商侧的配置完全一致。不要自行调用 Trim(),前后空格也是密钥内容的一部分。还要确认配置来自正确的环境,并检查其中是否意外包含引号或换行。

  4. 不要记录真实 secret

    排查问题时,可以记录 payload 的字节长度、签名长度和计算出的 digest,但不要将 signing secret 写入日志。生产环境也应谨慎记录完整的 webhook payload,其中可能含有敏感信息。

  5. 限制请求大小

    为了让示例更清楚,上面的代码使用 MemoryStream 读取完整正文。生产环境应根据业务情况限制 webhook 请求大小,防止过大的请求占用过多内存。

  6. 验证成功后再处理事件

    JSON 解析、数据库更新和其他业务操作都应放在签名验证之后。签名无效或格式错误时,应立即返回失败响应。

备注:内容仅供参考。