Skip to content
bitzorcas
中EN

Guide

Webhook 签名、重放防护与幂等消费

按当前 Webhooks 契约实现 HMAC-SHA256 验签,并补齐时间窗、事件去重、密钥轮换和失败证据。

Last updated

HTTPS 保护传输链路,却不能独立证明请求由预期订阅方发出。BitzOrcas 出站 Webhook 使用订阅 secret 对事件标识、Unix 毫秒时间戳和载荷 SHA-256 组成的规范原文计算 HMAC-SHA256。

当前签名契约

Payload JSON 原始文本

SHA-256 payloadHash

eventId

规范原文

timestamp ms

HMAC-SHA256 + subscription secret

X-BitzOrcas-Signature

规范原文严格为三行:

{eventId}
{unixTimestampMilliseconds}
{lowercaseSha256OfPayloadJson}

WebhookSignature.Compute 以 UTF-8 secret 创建 HMAC-SHA256,输出小写十六进制。Verify 重新计算后使用 CryptographicOperations.FixedTimeEquals 比较,降低时间侧信道风险。

出站请求头

当前交付服务写入固定的 Webhook header names,其中签名头是 X-BitzOrcas-Signature。接收方还必须取得同一次投递的 event id 与 timestamp,不能从 JSON 中猜测一个可能被业务序列化改变的值。

POST /hooks/bitzorcas HTTP/1.1
Content-Type: application/json
X-BitzOrcas-Event-Id: evt_01J2M7X5
X-BitzOrcas-Timestamp: 1784073600000
X-BitzOrcas-Signature: 2f07b2...
{"type":"ticket.created","data":{"ticketId":"T-1001"}}

头名以 WebhookHeaderNames 的发布版本为准。消费者应对 header 大小写不敏感,但对 event id、timestamp 文本值和 payload 字节保持严格一致。

接收方验签顺序

  1. 在读取业务 JSON 前取得 event id、timestamp 和 signature;缺失立即拒绝。
  2. 解析 timestamp 并检查允许时间窗,拒绝过旧或过度超前的请求。
  3. 缓冲原始请求体,按接收的 UTF-8 文本计算 SHA-256。
  4. 用当前订阅 secret 按三行契约计算期望签名。
  5. 做固定时间比较;失败时返回通用未授权结果。
  6. 以 event id 建立原子幂等记录,再执行业务事务。

时间窗和事件去重是消费者责任。WebhookSignature.Verify 只验证密码学签名,不读取当前时间,也不保存 event id;只调用该方法并不能防重放。

ASP.NET Core 接收示例

下面示例展示边界顺序。生产实现应把 secret 读取、时间源和 event store 抽象为可测试服务。

// 先缓存原始正文;反序列化再序列化会改变签名字节。
request.EnableBuffering();
using var reader = new StreamReader(request.Body, leaveOpen: true);
var payloadJson = await reader.ReadToEndAsync(cancellationToken);
request.Body.Position = 0;
// 载荷 hash、event id 和毫秒时间戳共同参与签名。
var payloadHash = WebhookSignature.ComputePayloadHash(payloadJson);
var valid = WebhookSignature.Verify(
secret, eventId, timestamp, payloadHash, suppliedSignature);
// 时间窗先拒绝过期请求;具体窗口由订阅契约确定。
var age = clock.UtcNow - DateTimeOffset.FromUnixTimeMilliseconds(timestamp);
if (age.Duration() > TimeSpan.FromMinutes(5))
return Results.Unauthorized();
// TryBeginAsync 必须原子化:只有一个并发消费者能开始该事件。
if (!await inbox.TryBeginAsync(subscriptionId, eventId, cancellationToken))
return Results.Ok();

已成功事件再次到达时返回 2xx 幂等成功,可防止发送方继续重试。正在处理或上次失败的事件需要单独状态与 lease,不能简单地永远标记“见过”。

为什么必须使用原始载荷

JSON 对象的空格、换行、属性顺序、Unicode 转义和数值格式都可能变化。发送方对 PayloadJson 的 UTF-8 文本计算 hash;接收方若先反序列化为对象再序列化,语义相同也可能得到不同字节。

反向代理不得解压后重写正文、转换字符集或以表单方式解析。如果链路组件必须变换载荷,应在变换后重新建立独立的签名契约,而不是继续声称验证了原发送方字节。

Secret 存储与轮换

Webhook subscription secret 在 Infrastructure repository 中通过 IWebhookSecretCipher 加密后持久化。加密静态存储降低数据库泄露风险,但运行时仍会取得明文签发,因此访问控制、日志脱敏和内存暴露仍需治理。

推荐轮换过程:

  1. 生成新 secret,保持旧 secret 可验证;
  2. 接收方先部署“双验证”,记录命中哪个 key version;
  3. 发送方切换到新 secret;
  4. 等待重试窗口和旧队列排空;
  5. 撤销旧 secret,并验证旧签名确实失败;
  6. 删除临时双验证逻辑或旧版本材料。

不要把 secret 放入 URL、事件 payload、错误响应或投递日志。投递日志可以保存 signature 用于诊断,但其访问权限和保留周期应按安全证据管理。

失败与重试语义

验签失败、timestamp 越界和 header 缺失通常返回 401/403,且不进入业务处理。格式正确但业务校验失败可以返回 4xx;临时依赖故障返回可重试状态。消费者必须明确哪些状态会触发发送端重试。

发送端至少一次投递意味着相同 event id 可能多次到达。重试会重新产生 delivery 记录和时间戳时,消费者仍应以稳定 event id 去重;不能只以签名或时间戳作为业务幂等键。

Node.js 验证示例

// rawBody 必须来自框架的原始字节缓冲,而不是 JSON.stringify(req.body)。
const payloadHash = createHash("sha256").update(rawBody).digest("hex");
const canonical = `${eventId}\n${timestamp}\n${payloadHash}`;
const expected = createHmac("sha256", secret).update(canonical).digest("hex");
// 长度不同先拒绝;长度一致时使用 timingSafeEqual。
const valid = expected.length === supplied.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(supplied));

契约测试

固定测试向量应由已知 secret、event id、timestamp、payload 和 expected signature 组成,并在所有官方 SDK/样例中共享。测试至少覆盖:

  • 相同输入产生稳定的小写十六进制签名;
  • 修改 event id、timestamp、payload 或 secret 任一项即失败;
  • 属性顺序或空白变化会导致 payload hash 改变;
  • 过期与未来 timestamp 在验签之外被拒绝;
  • 相同 event id 并发投递只产生一次业务副作用;
  • 新旧 secret 重叠窗口和旧 key 撤销;
  • 日志、ProblemDetails 和指标不包含 secret 或正文敏感字段。

当前能力边界

当前仓库的 WebhookSignature 已实现密码学原语,WebhookDeliveryService 已计算 payload hash、签名并写请求头,投递聚合也保留 event 与 signature 信息。但通用接收方 middleware、统一五分钟窗口和跨系统 inbox 不能由发送模块替外部消费者完成。

因此文档把“签名已交付”与“消费者防重放已完成”分开验收。商业交付应提供固定测试向量、接收方样例和 Consumer Contract Test,而不是只展示 Compute 的单元测试。

相关主题

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%