@ effect/webhooks
核实寄件人是否签了入境的网路快递,声称是寄件人寄来的,因为Effect v4. 预设覆盖了标准Webhooks(Svix,以及因此Clerk and Resend),GitHub,Frede和Sanity. 一个通用的HMAC头覆盖了Linear和其他赤色HMAC发送者,一个共享机密头覆盖了Cloudflare. 每一个验证器都是在纯数据之上的函数(标题查找、生体、接收时间和秘密),没有HTTP框架,没有 node: 导入,所以它运行在一个节点服务器和一个 Cloudflare Worker 类似.
预
1.0.0。 。 。 . 此软件包是@effected/*工具箱,在 稳定效果 v4并仍在0.x发展。 为了保持你的effect财务报告和审定财务报表@effect/*该工具包在线上构建和测试、安装@effected/pnpm-plugin-effected。 。 。 .稳定:不稳定. 此软件包的 API 表面尚未考虑 完成并可能改变
0.x释放 准确的版本。 满 政策: 释放策略。 。 。 .
为什么
网络呼克签名是HMAC,核心 effect/crypto是因为 Mac 计算一个。 难点在于每个供应商的阴谋
- 签名的字节 . 条纹和圣洁标志
${t}.然后是身体 和标准的Webhooks符号${id}.${timestamp}.然后是遗体。 每一个必须是生体字节,从不重复序列化的剖面. - 那个秘密. 标准 Webhooks 基础64 解码其
whsec_密入密钥 条纹使用whsec_密钥为字串。 将两个和每个签名悄悄地交换出不匹配. - 头语法. 几个
v1在秘密轮换期间可以有签名,v0和 SHA-1 标题必须被忽略,最后一个t赢了. - 令发. 在时间戳之前检查签名,所以一个没有签名的探测器不会得知容忍窗口.
- 钟. Staleness是根据你收到送货的时间而不是墙上钟来判断的,所以从排队中重新播放的送货,就像它到达时那样.
如果你只从发送者那里得到HMAC 没有时间戳的身体, HmacHeader 这里有几行 Hmac.verify 从 @effected/crypto 是同一个检查一个级别。 使用核心 Mac 只有在你自在地写解码 和经常的比较的时候.
安装
npm install @effected/webhooks effect
pnpm add @effected/webhooks effect
在节点上,它需要Node.js QQ24.11.0;它还运行在任何其他WebCrypto运行时间上,Cloudflare Workers也包括在内. effect v4是唯一的对等者,和 @effected/crypto 带着它 全体 @effected/* 软件包仅限无害环境管理.
提供什么
| 运行 | 要求数 |
|---|---|
StandardWebhooks.verify, (中文(简体) ), GitHub.verify, (中文(简体) ), Stripe.verify, (中文(简体) ), Sanity.verify, (中文(简体) ), HmacHeader.verify, (中文(简体) ), SharedSecret.verify, (中文(简体) ), SharedSecret.attest, (中文(简体) ), SharedSecret.verifyAttested | Mac.Mac |
Webhooks.checkFreshness | 无 |
无需核查 Digest 或者说 SecureRandom 本身; SecureRandom 是网络晶体 Mac 层被构建.
| 服务 | 任何 WebCrypto 运行时间( 包括工人) | 节点 |
|---|---|---|
Mac.Mac | Mac.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto)) 从 effect/crypto | NodeCrypto.layerMac 从 @effect/platform-node |
import { Layer } from "effect";
import { Mac, SecureRandom } from "effect/crypto";
const MacLive = Mac.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto));
构建一个 WebhookRequest
每个核查员都得 WebhookRequest(单位:千美元) header 搜索预设用小写名调用, 原始 body 字节 receivedAt 作为时间 DateTime.Utc,终点 secret 作为 Redacted 字符串,以销售商显示的形式显示。 修改取来的一个 Request,作为工人或任何以获取为基础的服务器接收:
import type { WebhookRequest } from "@effected/webhooks";
import { DateTime, Effect, Option } from "effect";
import type { Redacted } from "effect";
const fromRequest = (request: Request, secret: Redacted.Redacted<string>) =>
Effect.gen(function* () {
const body = new Uint8Array(yield* Effect.promise(() => request.arrayBuffer()));
const receivedAt = yield* DateTime.now;
const delivery: WebhookRequest = {
header: (name) => Option.fromNullishOr(request.headers.get(name)),
body,
receivedAt,
secret,
};
return delivery;
});
// Effect<WebhookRequest>
适配器不需要修剪或过滤. 预设将空值视为不存在,签名则预设了修剪值,并将空白值视为缺失值。 在重新处理存储的快递时,通过原始到达时间为 receivedAt。 。 。 .
验证交付
GitHub 图像
import type { WebhookRequest } from "@effected/webhooks";
import { GitHub } from "@effected/webhooks";
const verifyGitHub = (delivery: WebhookRequest) => GitHub.verify(delivery);
// Effect<void, WebhookVerificationError, Mac>
检查 x-hub-signature-256数字 : sha256= 和小写Hex HMAC-SHA256 被秘密的UTF-8字节所覆盖的身体. SHA-1号 x-hub-signature 被忽略。 GitHub 表示没有时间戳, 所以重播看起来完全像重递; 复制上 x-github-delivery。 。 。 .
条纹
import type { WebhookRequest } from "@effected/webhooks";
import { Stripe } from "@effected/webhooks";
const verifyStripe = (delivery: WebhookRequest) => Stripe.verify(delivery, { tolerance: "10 minutes" });
// Effect<StripeVerified, WebhookVerificationError, Mac>
检查 stripe-signature,列表 key=value 双人 最后一个有效 t (Unix秒)胜出,任意 v1 可能匹配,它覆盖一个秘密卷 当花纹标志 与两个秘密 v0 被忽略。 关键是 whsec_ 秘密字符串本身,未解码. StripeVerified 携带签名 timestamp。 。 。 . tolerance 是任意 Duration.Input 默认为 Webhooks.DEFAULT_TOLERANCE五分钟左右.
标准 Webhooks (Svix, 办事员, 重新发送)
import type { WebhookRequest } from "@effected/webhooks";
import { StandardWebhooks } from "@effected/webhooks";
const verifySvix = (delivery: WebhookRequest) => StandardWebhooks.verify(delivery);
// Effect<StandardWebhooksVerified, WebhookVerificationError, Mac>
读取 svix-id, (中文(简体) ), svix-timestamp 财务报告和审定财务报表 svix-signature,每个掉回 其无品牌 webhook- 名称。 签名头是一份空间分隔列表,其中包含 v1,<base64>,其中任何可能匹配的。 秘诀是 whsec_ (前缀是可选的)后有严格的,相加的基数64,其解码字节为键. StandardWebhooksVerified 携带信件 id发送者在每次重试时都会重复, 重复一遍,签名的 timestamp。 。 。 .
卫生
import type { WebhookRequest } from "@effected/webhooks";
import { Sanity } from "@effected/webhooks";
const verifySanity = (delivery: WebhookRequest) => Sanity.verify(delivery);
// Effect<SanityVerified, WebhookVerificationError, Mac>
检查 sanity-webhook-signature, (中文(简体) ), t=<unix milliseconds>,v1=<signature> 和一个 v1: 未添加基数64url HMAC-SHA256, 含 ${t}. 和尸体, 在秘密的UTF-8字节下。 请注意: t 以毫秒计,与斯德雷克不同. SanityVerified 携带签名 timestamp。 。 。 .
线性
没有线性预设, 因为线性不需要: 它用一个赤色的HMAC在身体上签名 linear-signature,哪个 HmacHeader 在JSON体内贴上时间戳 webhookTimestamp,以毫秒计。 验证签名,解码主体,然后检查新鲜度 Webhooks.checkFreshness数字 :
import type { WebhookRequest } from "@effected/webhooks";
import { HmacHeader, Webhooks } from "@effected/webhooks";
import { Effect, Schema } from "effect";
const LinearPayload = Schema.Struct({ action: Schema.String, webhookTimestamp: Schema.Number });
const verifyLinear = (delivery: WebhookRequest) =>
Effect.gen(function* () {
yield* HmacHeader.verify(delivery, { header: "linear-signature", encoding: "hex" });
const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(LinearPayload))(
new TextDecoder().decode(delivery.body),
);
yield* Webhooks.checkFreshness(payload.webhookTimestamp, delivery.receivedAt, Webhooks.DEFAULT_TOLERANCE);
return payload;
});
// Effect<{ readonly action: string; readonly webhookTimestamp: number }, WebhookVerificationError | SchemaError, Mac>
checkFreshness 使用一个 DateTime 或历时几毫秒。 无编号 DateTime 可代表(超出范围, NaN,无限)是 stale_timestamp永远不要,所以发送者提供的价值 可以直接进入。 选择的第四个参数为错误上的预设命名; 它默认为 HmacHeader。 。 。 .
自定义 HMAC 标题
import type { WebhookRequest } from "@effected/webhooks";
import { HmacHeader } from "@effected/webhooks";
const verifyCustom = (delivery: WebhookRequest) =>
HmacHeader.verify(delivery, { header: "x-signature", encoding: "base64", algorithm: "SHA-512", prefix: "sha512=" });
// Effect<void, WebhookVerificationError, Mac>
对于HMAC在秘密的UTF-8字节下单独将尸体送去的任何发送者来说. header 是小写头名, encoding 实值 hex 或加词 base64, (中文(简体) ), algorithm 实值 SHA-256 (默认)或 SHA-512,以及 prefix 是一个起始值的正文。 签名被解码并比作字节,所以在两种情况下的十六进制都会验证;未加码的碱基64不解码并是 bad_signature。这四个供应商预设了比较编码的字符串,因为他们的发送者只写一个拼写.
一个共同的秘密
import type { WebhookRequest } from "@effected/webhooks";
import { SharedSecret } from "@effected/webhooks";
const verifyCloudflare = (delivery: WebhookRequest) => SharedSecret.verify(delivery, { header: "cf-webhook-auth" });
// Effect<void, WebhookVerificationError, Mac>
对于一个将秘密本身放入标题的发送者来说,比如Cloudflare的 cf-webhook-auth,或路径隐藏您的代理移动到其中。 比较一下 ConstantTime.secretsEqual 从 @effected/crypto,MAC首先在新密钥下两侧,因此密钥的长度和相匹配的前缀都没有漏出. 这就是为什么它需要 Mac 我也一样。 精确的比对值 恰好是检索返回的, 从未被修剪过, 因为修剪会放松一个秘密的比较。 获取 Request是因为 Headers 已经将空白区域周围的条纹画出后, 您才能看到它, 所以通过一个添加密钥匹配的适配器 .
存档并重放一个共享的密件
共享秘密供应商的"签名"是路由秘密本身,所以原始交货的档案决不能保留该信头,没有它,存档交货就不能在重放时重新验证. SharedSecret.attest 薄荷能代替它存档 SharedSecret.verifyAttested 要么接受现场秘密 要么接受证据
import type { WebhookRequest } from "@effected/webhooks";
import { SharedSecret } from "@effected/webhooks";
import { Effect } from "effect";
const route = { header: "cf-webhook-auth", attestation: "x-archive-attestation", context: ["cloudflare", "production"] };
// On arrival: verify the live secret, then mint the attestation to archive instead of the secret header.
const receive = (delivery: WebhookRequest) =>
Effect.gen(function* () {
yield* SharedSecret.verify(delivery, { header: route.header });
return yield* SharedSecret.attest(delivery, { context: route.context });
});
// Effect<string, WebhookVerificationError | AttestationContextError, Mac>
// On arrival or on replay: the live secret when the delivery carries it, else the archived attestation.
const verifyRoute = (delivery: WebhookRequest) => SharedSecret.verifyAttested(delivery, route);
// Effect<void, WebhookVerificationError | AttestationContextError, Mac>
一种证明是小写为hex HMAC-SHA256,在路由秘密的UTF-8字节下,一个域相分离的前缀,由生体接后. 前缀是 effected-attestation.v1.<count>.<part>.<part>.: 已版本标记 (SharedSecret.ATTESTATION_TAG),上下文部分的数目,然后是每个部分,然后是每个部分 .用于 ["cloudflare", "production"] 这是 effected-attestation.v1.2.cloudflare.production.标记保留了一种证明,因为任何供应商的HMAC在重复使用的秘密下将尸体翻倍,上下文将它与它的路线相接,伯爵也保留了一部分不越过边界进入身体. 每个上下文部分必须非空而不得包含 .; 否则这两个函数都会失败 AttestationContextError (与犯罪 index (单位:千美元) reason 联 合 国 empty 或者说 contains_separator),在读出任何标题之前.
verifyAttested 跑两步 何时 header 送货时带有非空值 将现场秘密比作原始信息 SharedSecret.verify 比较一下,这个答案是最后的:一个错误的秘密是 bad_signature 哪怕是有效的证明 白空间值也是错误的秘密 否则它会读到 attestation 头和检查它与 Hmac.verify 从 @effected/crypto,它解码了六分音符,并在常时比较字节,所以两种都验证. 都没有 missing_signature关闭 header 只接受证明。 将证明书以供应商从未发送过的标题归档, 并剥去信头 从电线上到来的东西, 所以证明书只从你的档案里读回来。 伪造一个需要这个秘密,所以接受这个秘密是不会承认的.
错误
每个预设 verify 失败 WebhookVerificationError 一个人 它携带 reason,路由键; preset,正在核实计划(StandardWebhooks, (中文(简体) ), GitHub, (中文(简体) ), Stripe, (中文(简体) ), Sanity, (中文(简体) ), HmacHeader 或者说 SharedSecret)和可选人可读 detail路径 reason永远不要 message 或者说 detail从来不带秘密 签名或尸体.
reason | 含义 | 典型状态 |
|---|---|---|
missing_signature | 签名头,或用于 SharedSecret 密信头, 缺席或为空 . | 401 (中文(简体) ) |
bad_signature | 没有签名匹配, 或者签名头已存在, 但它的语法除外: 一个不是 GitHub 值 sha256= 和64个小写十六进制数字,一个没有的条纹标题 t 或者没有 v1,一个不是 t=<ms>,v1=<sig>,一个缺失 HmacHeader 前缀,或不解码的签名. | 401 (中文(简体) ) |
malformed | 除签名外, 方案需要的页眉是不存在的或形状不好的: 一个标准的 Webhooks id 或 timettamp, 或者一个不是 Unix 秒 1 到 15 位数的时标 . | 400个 |
stale_timestamp | 签名匹配,但签名的时间戳 坐在容忍度之外 receivedAt不管怎样 分界线是新鲜的. | 400个 |
import type { WebhookVerificationError } from "@effected/webhooks";
const statusFor = (error: WebhookVerificationError): 400 | 401 =>
error.reason === "missing_signature" || error.reason === "bad_signature" ? 401 : 400;
因为签名是先检查的, 一份有差错签名报告的烂快递 bad_signature。 。 。 . SharedSecret.attest 财务报告和审定财务报表 SharedSecret.verifyAttested 也可以失败 AttestationContextError 对于空或包含的上下文部分 .;它被打出是因为一个上下文通常是从路由来构建的,所以一个呼叫者可以回答它而不是崩溃. 接线错误是缺陷,而不是被键入的失败:不是的标准 Webhooks 密钥 whsec_ base64, 用于 HMAC 预设的空密件( 该核心 ) Mac 拒绝为 InvalidKey),或 a CryptoError 行为不检 Mac 层。 在 WebCrypto 层上, 无运行时间 crypto.subtle 也会死. WebhookVerificationReason 财务报告和审定财务报表 WebhookPreset 以图示导出.
测试
包裹没有测试双倍。 提供 MacLive 从 提供什么 并在测试中建立交付,以测试中以外的执行方式签署,例如 node:crypto是因为 createHmac,或供应商自己的SDK(如果有的话)stripe是因为 generateTestHeaderString, (中文(简体) ), svix是因为 Webhook.sign) (中文(简体) ). 通过固定 receivedAt 并用时间戳签,不需要测试 TestClock打开 Mac.layerWebCrypto 每个 verify 等待一个真正的承诺, 所以测试叉一个然后调整 TestClock 可以吊起:等待它,或者把钟锁上。 测试失败路径以及幸福路径: 以完全一个方式改变每个签名字节的每个部分并断言 bad_signature。 。 。 .
它不会做的
- 决定交付身份. 它返回签名的部分(电文ID和计划所在的时间戳);去重复、路由和供应商命名都是你的.
- 解码有效载荷. 解码已核实
body用你自己的计谋. - 环绕 HTTP 框架 . 输入是纯数据,所以符合核心
effect/http一个工人Request或者没有适配器包的测试. - 读取壁钟. 新鲜度总是根据
receivedAt你通过.