Webhook 签名的目标是让接收端确认“请求体没有被篡改,发送方持有共享密钥”。它不自动证明业务用户身份,不替代 TLS,也不提供防重放状态。接收方必须完整实现 timestamp window 与 EventId 去重。
1. 精确的 wire contract
发送端以原始 payloadJson 的 UTF-8 字节计算 SHA-256:
payloadHash = lowercase_hex(SHA256(UTF8(payloadJson)))canonical = eventId + "\n" + unixMilliseconds + "\n" + payloadHashsignature = lowercase_hex(HMAC_SHA256(UTF8(secret), UTF8(canonical)))随后发送:
POST /v1/bitzorcas/events HTTP/1.1Content-Type: application/json; charset=utf-8X-BitzOrcas-Event-Id: 019c48cb7ba47000953d5af574ddc531X-BitzOrcas-Event-Type: files.finalizedX-BitzOrcas-Timestamp: 1784102400123X-BitzOrcas-Payload-Hash: 3e478f21bc9e1a84f395d824d55b018b1081395fc02d847936a282924a2ef932X-BitzOrcas-Signature: 813a6bc7d921e42f9b89154a32014cd7ea310f852b719463b2160d5e128174f9
{"fileId":"file-42","status":"Finalized"}EventType、TargetUrl、HTTP method、Content-Type 和 subscription ID 不进入 canonical input。接收端可以把这些字段纳入自身授权判断,但若要修改签名版本,必须引入显式版本头,不能悄悄改变 canonical string。
2. ASP.NET Core 接收端验签
验签必须在 JSON model binding 改写或丢弃原始请求体之前完成。不要先反序列化对象再重新序列化:属性顺序、空白、转义或数字格式变化都会得到不同 hash。
app.MapPost("/v1/bitzorcas/events", async ( HttpRequest request, IWebhookReplayStore replayStore, ISecretStore secrets, CancellationToken cancellationToken) =>{ request.EnableBuffering(); using var reader = new StreamReader( request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); var payload = await reader.ReadToEndAsync(cancellationToken); request.Body.Position = 0;
var eventId = request.Headers["X-BitzOrcas-Event-Id"].ToString(); var timestampText = request.Headers["X-BitzOrcas-Timestamp"].ToString(); var claimedHash = request.Headers["X-BitzOrcas-Payload-Hash"].ToString(); var claimedSignature = request.Headers["X-BitzOrcas-Signature"].ToString();
if (!long.TryParse(timestampText, CultureInfo.InvariantCulture, out var timestamp)) return Results.Unauthorized();
// 5 分钟窗口只是示例;它应与双方时钟、队列延迟和轮换策略一起评审。 var sentAt = DateTimeOffset.FromUnixTimeMilliseconds(timestamp); if ((DateTimeOffset.UtcNow - sentAt).Duration() > TimeSpan.FromMinutes(5)) return Results.Unauthorized();
var computedHash = WebhookSignature.ComputePayloadHash(payload); if (!CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(computedHash), Encoding.ASCII.GetBytes(claimedHash))) return Results.Unauthorized();
var secret = await secrets.GetCurrentAsync(cancellationToken); if (!WebhookSignature.Verify( secret, eventId, timestamp, computedHash, claimedSignature)) return Results.Unauthorized();
// 验签成功后原子登记 eventId;重复请求返回 2xx,但不重复执行业务副作用。 if (!await replayStore.TryBeginAsync(eventId, cancellationToken)) return Results.Ok(new { duplicate = true });
await ProcessEventAsync(payload, cancellationToken); await replayStore.MarkCompletedAsync(eventId, cancellationToken); return Results.Ok();});生产实现还应限制请求体大小,验证 Content-Type,记录 KeyId/签名版本,使用可信代理后的 TLS,并把失败原因以低基数指标暴露,不能把 secret 或完整签名写日志。
3. EventId 去重不能只放内存
发送端以 (EventId, SubscriptionId) 幂等,但不同订阅会收到同一个 EventId。接收端通常只处理自己的一个订阅,可用 EventId 作为唯一键;若多个订阅汇聚同一接收服务,则用 (SubscriptionId, EventId) 或发送端新增 SubscriptionId header。
去重记录至少需要 Processing/Completed 状态、首次时间和过期时间。先执行业务再写去重会在崩溃窗口重复;先写 Completed 再执行业务会丢事件。使用同一数据库事务,或以 inbox 状态机提供可恢复的 Processing lease。
4. 一次性 secret 的生命周期
WebhookSubscription.Create 通过 RandomNumberGenerator.GetBytes(32) 生成 256-bit 随机值,并以 Base64 返回。聚合同时保存 SHA-256 SecretHash 和用于签名的 SecretMaterial。创建 API 返回明文一次;查询摘要不返回任何 secret 字段。
正确处理:
- 管理 UI 只显示一次,并提供“已安全保存”确认;
- 外部应用写入 Secret Manager/KMS/Vault,不写 appsettings、CI 日志或工单;
- BitzOrcas DataProtection key ring 持久化并跨实例共享;
- 轮换前确认接收端可同时接受 current/previous;
- 泄露时执行紧急轮换、暂停订阅、审计和回放检查。
SecretHash 当前没有参与管理 API 验证、索引或审计;它只是聚合状态。不要误写成“框架通过 hash 验证回调客户端”。
5. 静态加密的真实写路径
Infrastructure 默认注册 WebhookSecretDataProtectionCipher,purpose 为 Webhooks.SecretMaterial。Repository SaveAsync 在 Add/Update 前调用 Protect,然后用 ApplyEncryptedSecrets 把聚合内字段替换为密文。投递前 Unprotect 一次。
DataProtection 不是外部 KMS 审计的替代品。密钥环丢失会让历史 SecretMaterial 无法解密;当前 Unprotect 捕获所有异常并原样返回输入,以兼容旧明文。这意味着密钥环损坏不会 fail closed,而会把密文当 HMAC secret 继续发送,接收端只看到签名突然失败。
6. 当前重复 Save 会双重加密
现有 Repository 不判断 SecretMaterial 是否已保护:
// 第一次创建:plaintext -> Protect -> ciphertext-1,正确。await repository.SaveAsync(created, cancellationToken);
// 之后从 DB 读到 ciphertext-1,暂停/更新没有改 secret。var loaded = await repository.FindAsync(tenantId, id, cancellationToken);loaded.Value!.Suspend();
// SaveAsync 再次 Protect:ciphertext-1 -> ciphertext-2。await repository.SaveAsync(loaded.Value, cancellationToken);
// 投递只 Unprotect 一层,得到 ciphertext-1,并把它当 secret 计算 HMAC。双 ORM parity 测试验证保存、读取与投递行,但没有验证“更新订阅后仍用原明文 secret 产生相同签名”。这是 GA 阻断缺陷。修复方式不能只在 Unprotect 循环解密,因为无法安全区分任意历史明文和密文层数。
推荐把领域模型中的 secret 与 persistence ciphertext 分离,或为密文增加显式版本/前缀并让仓储只加密明文状态。至少新增:Create→Update→Deliver、Suspend→Resume→Deliver、连续多次 Save、轮换 previous key 的双 ORM 回归。
7. 轮换重叠窗口的当前语义
RotateSecret:
- Deleted 返回
Webhook.Deleted;Suspended 仍可轮换; - 生成新 secret;
- 把当前 SecretMaterial 移到 PreviousSecretMaterial;
- PreviousSecretExpiresAt=
now + overlap,默认 300 秒; - 更新 hash、current material 和 RotatedAt;
- 返回新明文一次。
但当前发送服务始终只用 current SecretMaterial 签名,没有“失败后用 previous 重签”;框架也没有接收 Webhook 的验签端点使用 previous。上一份密钥保存后不会被任何运行路径读取,过期后也没有清除任务。
“重叠窗口”真正需要由外部接收端实现:在部署新 secret 的过渡期同时接受 current/previous,并按 KeyId 选择。仅在发送端数据库保留 previous 并不能帮助外部系统验证已经在途的旧请求。
8. 推荐的版本化轮换协议
协议至少增加 X-BitzOrcas-Signature-Version 与 X-BitzOrcas-Key-Id。轮换最好分 prepare/activate/retire,而不是一个命令立即切换发送密钥。Retire 时间应覆盖最大排队时间、最大自动重试时间和时钟偏差,并有审计事件。
9. 迁移端口的边界
WebhookSecretMigrationAdapter 跨租户扫描所有订阅:能被 DataProtection 解开则 skipped,否则当作历史明文 Protect 后回写。该判定把“使用旧/错误 key ring 加密的密文”也视为明文,再包一层当前密钥。迁移前必须保证历史 key ring 可用,并在抽样环境做 decrypt/signature 验证。
迁移没有分页、checkpoint、分布式锁或失败汇总;大表执行可能占用内存并重复扫描。它还只在 current 已保护时直接 skip,不单独验证 previous 是否已保护。
10. 必测安全契约
- 公开固定 test vector:secret、payload、timestamp、expected hash/signature;
- JSON 空白、属性顺序、Unicode、换行与大 payload 的原始字节行为;
- 缺头、重复头、超长头、错误 hex 和固定时间比较;
- timestamp 过旧/未来、EventId 原子去重和 Processing 崩溃恢复;
- Create/Update/Suspend/Resume 后 signature 不变;
- Rotate 后新 key 生效,old key 在约定窗口可验,窗口后拒绝;
- DataProtection key ring 跨实例、轮换、丢失和恢复演练;
- 明文迁移 checkpoint、错误 key ring、current/previous 混合状态;
- 日志、trace、Problem Details 与审计中均不出现 secret/payload;
- 紧急轮换和订阅暂停的值班 Runbook。
11. 审查命令
# canonical input 与头名称是公共协议,变更必须版本化。rg -n "WebhookSignature|WebhookHeaderNames|HMACSHA256|PayloadHash" \ src/Platform/Webhooks -g '*.cs'
# 暴露重复 Protect 与宽松 Unprotect fallback。rg -n "Protect\(|Unprotect\(|ApplyEncryptedSecrets|catch" \ src/Platform/Webhooks -g '*.cs'
# 上一份密钥当前预期只被写入;修复后应出现真实验证/清理路径。rg -n "PreviousSecretMaterial|PreviousSecretExpiresAt" \ src/Platform/Webhooks -g '*.cs'