erlang_pay 系列第二篇。这篇不限于 Erlang——不管你用什么语言接支付,回调验签的原理是同一套。
第一篇:erlang_pay:为 Erlang 补上支付这块拼图
想象一个场景。
你的服务器收到一条 HTTP 请求,内容大意是:
“你好,我是支付宝。订单 R123,用户已付款 1000 元,请发货。”
问题来了:你怎么知道这条消息真的来自支付宝?
发送方可能是支付宝的通知服务器,也可能是我——一台笔记本、一条curl命令就够了。如果你的服务器看到"支付成功"四个字就发货,那你的商品等于免费送:任何人只要知道你的回调地址和订单号格式,就能伪造回调。
防这个的机制就叫验签。用公文打个比方:支付宝发来的每条通知都盖了一个防伪章,你的服务器收件后,第一件事不是拆包裹,而是验章。章是假的,包裹直接扔掉。
erlang_pay 的铁律:先验章,再拆包
这个库把"先验签、后解析"写成了不可绕过的规矩:验签失败,报文一个字节都不解析。不是"解析的时候小心一点",而是根本不碰。
为什么顺序这么重要?因为解析报文本身就有风险——恶意构造的数据可能在解析阶段触发各种意想不到的路径。章都没验,凭什么拆人家的包裹?
三家机构的"防伪章"做法各不相同,erlang_pay 三种都实现了:
| 机构 | 章 | 你验章用的钥匙 |
|---|---|---|
| 支付宝 | RSA2 签名 | 支付宝公钥 |
| 微信 v3 | 平台私钥签名 + AES-256-GCM 加密的敏感字段 | 微信平台公钥 |
| Stripe | HMAC-SHA256 | webhook secret(开通 webhook 时给的那串) |
前两种是非对称签名:支付宝/微信用它们自己保管的私钥盖章,你用公开的公钥验章。私钥不离手,所以别人盖不出同样的章——这就是"防伪"的原理。Stripe 更轻量:双方共享同一个 secret,各自对报文算一遍 HMAC,对得上就是真件。
三个容易漏掉的防伪细节
章的机制各家文档都写了,但下面三个细节,不做也不会立刻出事——出事的时候你甚至查不到被攻击了。erlang_pay 把它们做进了代码里。
细节一:防重放——门票当日有效
攻击者拿到一条真实的回调报文(比如从某处日志、网络截获),自己不动手伪造,直接原样重发 100 遍呢?签名是真的,验签全过——你的服务器就把同一个订单"发货"100 次。
防法很朴素:真通知里带时间戳,过期不认。微信的回调带Wechatpay-Timestamp头,erlang_pay 只接受与服务器时间相差±300 秒以内的通知(源码里epay_wechat.erl的?NOTIFY_TOLERANCE = 300)。五分钟前的真件尚且拒收,昨天捡来的票自然进不了场。
细节二:常量时间比较——对暗号不能露表情
验签的最后一步是比较两个字符串:算出来的签名和对方带来的签名。直觉写法是逐字符比,第一个字符不同就返回"假"。
问题出在"返回得快"上。逐字符比较遇到第一位不同立刻结束,遇到完全相同才比到最后——耗时不一样。攻击者可以掐着秒表一位一位试:"以a开头的签名响应是不是快一点点?"这就叫计时攻击:不用破解密码学,靠"表情"猜出答案。
erlang_pay 里验 HMAC 用的是自己实现的常量时间比较(epay_crypto:constant_time_equal/2),核心就一行循环:
ct_equal(<<A,RA/binary>>,<<B,RB/binary>>,Acc)->ct_equal(RA,RB,Accbor(AbxorB)).不管哪一位不同,都把全部字符比完才给答案——比较过程"没有表情"。顺带说明:两个串不等长时直接返回 false、不用逐字节比——用源码注释的原话说,“长度本身非秘密”。
细节三:章是真的,还要核对"收件人"
假设验签通过——这确实是支付宝发的通知。还有一步:是发给你的吗?
一个支付宝开放平台可以挂多个应用,每家商户有自己的app_id。一条发给别人的支付成功通知(别人的订单),签名同样是真的。所以 erlang_pay 验完签还要核对通知里的app_id和你Cfg里的是否一致,不一致返回app_id_mismatch。
门卫验了工牌是真工牌,还得对一下名单:这位今天约的是不是你这间办公室。
最重要的一条:验签通过 ≠ 可以发货
这是本篇最想纠正的误区。很多教程的代码长这样:验签 → 拿到订单号 → 标记已支付 → 发货。中间缺了两步,钱就漏了。
erlang_pay 的 README 里写明了阶段语义,值得每个做支付的人都抄下来:
verify_notify 通过 只证明一件事:AUTHENTICATED —— 这条通知确实来自支付机构,没被篡改 它不证明: ORDER_MATCHED —— 通知里的金额、订单,和你数据库里的对得上 IDEMPOTENT —— 这条通知你没处理过(重发的通知会来很多次) POSTABLE —— 这笔账现在该不该入(订单状态可能不对)验签只回答"章是真的"。名单要你自己对,账要你自己记。回调可能来多次(微信明确说通知可能重复),同一单可能有多条金额不同的通知,这些去重和匹配逻辑是你的业务责任,库替你做不了,也不该替你做。
附:一个"宁可功能少"的工程决策
0.2 版本里 erlang_pay 有个模块epay_cert_mgr,负责微信平台证书的自动下载、轮换和序列号管理。0.3.0 把它删了。
原因写在 CHANGELOG 里:证书生命周期的"首次信任→轮换→serial 消费"没有做成闭环,留着它就有"看起来生产可用"的嫌疑。改成调用方自己注入平台公钥(platform_public_key),库只管验,不管证书从哪来。
做出"删功能"的决定比堆功能难。一个支付库里每个看起来自动化的环节,都对应一份"出了问题算谁的"的责任——没把握闭环的,宁可摆到明面上让你自己管。
验签失败长什么样
所有拒绝都有明确的错误码,方便你排查"为什么回调没生效":
| 错误码 | 人话 |
|---|---|
bad_signature | 章是假的,或报文被改过 |
missing_signature | 没带章 |
serial_mismatch | 章是真的,但用的钥匙版本对不上(微信证书/公钥序列号不符) |
timestamp_expired | 通知太旧,疑似重放 |
app_id_mismatch | 章真,但收件人不是你 |
每一条都对应上面讲的一个防伪环节。
仓库信息
- GitHub: https://github.com/imboy-pub/erlang_pay
- Gitee: https://gitee.com/imboy-pub/erlang_pay
- Gitcode: https://gitcode.com/imboy/erlang_pay
- 协议:Apache-2.0
- 版本:0.3.0(2026-09-23),21 个提交,从 2026-06-14 到 2026-09-23
- 自检命令:
rebar3 eunit(213 用例)、rebar3 dialyzer(零警告)、bash scripts/gate.sh(清洁编译+单测+类型+打包全门)
本文涉及代码均可复核:src/epay_wechat.erl(?NOTIFY_TOLERANCE = 300)、src/epay_crypto.erl:136(constant_time_equal/2)、src/erlang_pay.erl(门面与错误合同)、README「安全要点 / 错误码」。
IMBoy(一个开源可私有化的 IM 平台,Erlang/OTP 后端)要收钱,就有了这个库。erlang_pay 从 IMBoy 项目里长出来,然后独立成仓回馈社区——它不绑定 IMBoy,任何需要接支付渠道的 Erlang/OTP 项目都可以直接拿去用。
系列第三篇:日元没有"分",退款要带流水号:支付代码里两条要命的规矩