HTTPS 保护传输链路,却不能独立证明请求由预期订阅方发出。BitzOrcas 出站 Webhook 使用订阅 secret 对事件标识、Unix 毫秒时间戳和载荷 SHA-256 组成的规范原文计算 HMAC-SHA256。
当前签名契约
规范原文严格为三行:
{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.1Content-Type: application/jsonX-BitzOrcas-Event-Id: evt_01J2M7X5X-BitzOrcas-Timestamp: 1784073600000X-BitzOrcas-Signature: 2f07b2...
{"type":"ticket.created","data":{"ticketId":"T-1001"}}头名以 WebhookHeaderNames 的发布版本为准。消费者应对 header 大小写不敏感,但对 event id、timestamp 文本值和 payload 字节保持严格一致。
接收方验签顺序
- 在读取业务 JSON 前取得 event id、timestamp 和 signature;缺失立即拒绝。
- 解析 timestamp 并检查允许时间窗,拒绝过旧或过度超前的请求。
- 缓冲原始请求体,按接收的 UTF-8 文本计算 SHA-256。
- 用当前订阅 secret 按三行契约计算期望签名。
- 做固定时间比较;失败时返回通用未授权结果。
- 以 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 加密后持久化。加密静态存储降低数据库泄露风险,但运行时仍会取得明文签发,因此访问控制、日志脱敏和内存暴露仍需治理。
推荐轮换过程:
- 生成新 secret,保持旧 secret 可验证;
- 接收方先部署“双验证”,记录命中哪个 key version;
- 发送方切换到新 secret;
- 等待重试窗口和旧队列排空;
- 撤销旧 secret,并验证旧签名确实失败;
- 删除临时双验证逻辑或旧版本材料。
不要把 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 的单元测试。