如何将验证 webhook 请求的 Python 代码转换为 C#
结论
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)
普通字符串比较不具备签名验证所需的固定时间语义。
仍然不匹配时重点检查
-
尽早启用缓冲
Request.EnableBuffering()必须在任何组件读取正文之前调用。如果正文已经被读取,之后再启用缓冲通常无法恢复已消耗的内容。必要时,将验证逻辑移到请求管道中更靠前的中间件里。 -
不要同时使用
[FromBody]如果 action 参数通过
[FromBody]接收对象,ASP.NET Core 模型绑定可能会先读取正文。应直接读取原始 Body,签名验证成功后再手动反序列化。 -
检查 signing secret
本地 secret 必须与提供商侧的配置完全一致。不要自行调用
Trim(),前后空格也是密钥内容的一部分。还要确认配置来自正确的环境,并检查其中是否意外包含引号或换行。 -
不要记录真实 secret
排查问题时,可以记录 payload 的字节长度、签名长度和计算出的 digest,但不要将 signing secret 写入日志。生产环境也应谨慎记录完整的 webhook payload,其中可能含有敏感信息。
-
限制请求大小
为了让示例更清楚,上面的代码使用
MemoryStream读取完整正文。生产环境应根据业务情况限制 webhook 请求大小,防止过大的请求占用过多内存。 -
验证成功后再处理事件
JSON 解析、数据库更新和其他业务操作都应放在签名验证之后。签名无效或格式错误时,应立即返回失败响应。
备注:内容仅供参考。
