Apple IAP 服务端通知 V2:x5c 里的证书不能直接用来验签
iOS 虚拟商品走内购,服务端接 App Store Server Notifications V2。通知能收到,notificationType 能解出来,signedTransactionInfo 里的 productId、transactionId 也都拿到了,交易入库、发货、对账——一路通畅。
问题出在第二天:有人用一条自己拼的 signedPayload 打回调地址,库里多了一笔不存在的交易。
验签这层最容易被跳过,下面写清楚它该怎么做,以及接完通知之后真正会卡住上线的几个字段。
字段口径来自 Apple 官方文档与 WWDC22「Explore in-app purchase integration and migration」。
一、通知本身只有一个字段
请求体:
POST /api/apple/notify HTTP/1.1
Content-Type: application/json
{"signedPayload":"eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTUQuLi4iXX0.eyJub3RpZmljYXRpb25UeXBlIjoiRElEX1JFTkVXIiwi...cw.MEUCIQDw..."}
signedPayload 是 compact 序列化的 JWS,三段 Base64URL,用 . 分隔:header、payload、signature。
解出来:
{
"notificationType": "DID_RENEW",
"notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20",
"version": "2.0",
"signedDate": 1783142400000,
"data": {
"appAppleId": 1234567890,
"bundleId": "com.example.app",
"environment": "Production",
"signedTransactionInfo": "",
"signedRenewalInfo": ""
}
}
signedTransactionInfo 和 signedRenewalInfo 本身又是 JWS,套在签名过的外层里。外层验过,内层的内容就是可信的;单独把它们拎出来验也不算错,只是多一步。
二、最容易写错的一处:用 x5c 里的证书直接验签
JWS header 里带 alg: ES256 和 x5c——一段 Base64 编码的证书链。
于是很多人这么写:解 header → 取 x5c[0] → 用它的公钥验 signature → 通过。看上去很严谨,实际等于没验。
因为 x5c 是请求体的一部分,攻击者完全可控。他可以自己生成一对 P-256 密钥,自签一条链塞进 x5c,用自己的私钥签 payload。你的验签逻辑逐条对得上,于是接受了一笔不存在的订单。
正确顺序是锚定根证书:
- 从 JWS header 取
x5c,Base64 解码成 PEM - 用本地的 Apple Root CA - G3 作为信任锚,校验证书链
- 确认链上的叶子证书确实是 App Store 用的那张
- 从验过的叶子证书取公钥,验 JWS signature
根证书从 Apple PKI 页下载(),放在自己的镜像里,别在运行时去下载。
x5c 的顺序坑在于:Apple 发出来的数组是叶子在前、根在后,而不少语言库(OpenSSL 的 X509_STORE_CTX 这类)期望你从叶子开始一级级往上追。Elixir/Erlang 的 :public_key 那种要求 root-first 的,就要先 reverse。写完先用一条真通知跑一次,别猜。
用 openssl 手工核一遍:
# header 里的 x5c 每项单独落盘
echo "$X5C_0" | base64 -d > leaf.pem
echo "$X5C_1" | base64 -d > inter.pem
echo "$X5C_2" | base64 -d > root.pem
openssl x509 -in leaf.pem -noout -issuer -subject -dates
openssl verify -CAfile AppleRootCA-G3.pem -untrusted inter.pem leaf.pem
openssl verify 输出 OK,才说明这条链真的能锚到 Apple。中途报 unable to get local issuer certificate,就是少装了一个中间证书。
想省事就用官方服务端库:app-store-server-library(有 Node、Java、Python、Go、PHP 等语言实现),它把根证书、链校验、bundleId/appAppleId/environment 比对都写好了。自己撸的时候照着它的校验顺序抄。
三、只解 payload 不验签,是最常见的线上事故
// 别这么写
const payload = JSON.parse(Buffer.from(signedPayload.split(".")[1], "base64url"));
if (payload.notificationType === "ONE_TIME_CHARGE") { /* 发货 */ }
Base64URL 解码不是验证。任何人都能 POST 到这个 URL,包括扫描器。上线前跑一次:用自己的机器 curl 一条伪造 body 过去,看服务端是 200 还是 4xx。
验签失败的正确响应是 4xx 并且不落库,不是记一条日志然后继续往下走。
四、environment 与 bundleId 不校验,沙盒数据会进生产库
通知里带 environment: Sandbox | Production。App Store Connect 允许给两个环境配不同 URL,但图省事共用同一个 URL 的团队很多,于是:
- 测试同学在沙盒买一单,生产库里多了一笔真实订单
- 你发了货,对账时发现 App Store 那边没有这笔钱
同一个接口里要做的三件事:
$decoded = $verifier->verifyAndDecodeNotification($signedPayload);
$data = $decoded['data'] ?? $decoded['summary'] ?? $decoded['externalPurchaseToken'] ?? $decoded['appData'] ?? [];
if ($data['bundleId'] !== 'com.example.app') { return 403; }
if ($data['environment'] !== 'Production') { return 200; } // 单独落 sandbox 表,不进业务库
bundleId 的校验挡的是“别人家的通知打到你这里”。appAppleId 同理,多 App 共用一个网关时尤其要查。
注意 data 不一定存在:TEST 类型没有 data,RENEWAL_EXTENSION 用 summary,外部购买令牌用 externalPurchaseToken。先判空再取字段,不然上线当天就会被一条 TEST 通知打成 500。
五、金额是“千分之一货币单位”,不是分,也不是元
signedTransactionInfo 里:
{ "price": 39990, "currency": "USD", "productId": "vip_month" }
price = 39990 表示 39.99,单位是 milliunits of currency,也就是货币的千分之一。不是美分(那是 3999),更不是元。
串起本站已经写过的那几个单位:
| 渠道 | 字段 | 单位 | 39.99 写成 |
|---|---|---|---|
| 微信支付 | amount.total | 分 | 3999 |
| 支付宝 | total_amount | 元,字符串两位小数 | "39.99" |
| Apple IAP | price | 千分之一货币单位 | 39990 |
| Stripe | amount | 最小货币单位,JPY 这类零小数位例外 | 3999 |
四套单位混在一个对账表里,金额差 10 倍或 100 倍是常规操作。入库统一存最小单位整数,字段名带上单位后缀(amount_milli)。
currency 单独给,不要从 storefront 推。
六、appAccountToken 漏传,通知来了不知道是谁
这是掉单最实际的根因。通知里有 transactionId、originalTransactionId、productId,唯独没有你的 userId——除非你购买时传了 appAccountToken。
StoreKit 2 的下单:
let result = try await Product.products(for: ["vip_month"]).first!.purchase(
options: [.appAccountToken(UUID(uuidString: userUUID)!)]
)
之后这笔交易的 signedTransactionInfo、续订信息和通知里都会带上这个 UUID。StoreKit 1 的对应字段是 applicationUsername。
漏传的后果不是“通知收不到”,而是“通知收到了,但落不到用户头上”。补救只能靠客户端在支付成功后调一次自己的绑定接口——而这个接口本身会失败、会被杀进程、会在弱网下重试丢单。
规则很明确:appAccountToken 必须在购买那一刻传,并且是你内部用户 ID 派生出的固定 UUID(同一用户每次一致),不要每次购买新生成一个。
七、耗时的处理放在回调里,Apple 会一直重发
通知接口要在很短时间内返回 2xx,Apple 才认为投递成功;超时或非 2xx 会按策略重试:首次失败后 1 小时,然后 12、24、48、72 小时。
所以回调里只做三件事:验签、按 notificationUUID 做幂等落库、丢队列,然后立刻返回 200。发货逻辑放队列里跑。
notificationUUID 在重试时保持不变,这就是幂等键。用 transactionId + notificationType 也行,但 notificationUUID 是官方给的那个。
八、漏掉的通知怎么补
服务端回滚、域名切错、证书过期几天没发现——都会漏。补单不要靠让用户重装 App,用 App Store Server API 查。
两个 host:
- 生产:
https://api.storekit.itunes.apple.com - 沙盒:
https://api.storekit-sandbox.itunes.apple.com
关键端点:
GET /inApps/v1/transactions/{transactionId}
GET /inApps/v1/subscriptions/{transactionId} # 订阅状态,用 originalTransactionId
GET /inApps/v1/history/{transactionId} # 交易历史
PUT /inApps/v1/transactions/consumption/{transactionId} # 回复 CONSUMPTION_REQUEST
鉴权 header 是 Authorization: Bearer ,JWT 用 App Store Connect 生成的 In-App Purchase 私钥签,ES256,claims 四个:
iss = issuerId(App Store Connect 里的 Issuer ID)
iat = 签发时间
exp = 过期时间,最长 60 分钟
aud = "appstoreconnect-v1"
bid = bundleId
kid = 在 JWT header 里,不是 claim
exp 超过 60 分钟会被直接拒。别把 token 设成一天然后缓存一天。
transactionId 每次续订都会新生成,originalTransactionId 才是订阅链的主键。查订阅状态用后者,用错会查不到或者查到单次交易。
九、退款与撤销
REFUND 通知里 revocationDate、revocationReason 有值,按这个时间点收权益,不是按收到通知的时间。
REFUND_REVERSED 是 Apple 撤销了之前的退款(用户申诉成功),之前扣掉的权益要还回去。
DID_FAIL_TO_RENEW 带 GRACE_PERIOD 子类型时,宽限期内继续给服务;不带子类型就是没有宽限期,可以断。Apple 会在 60 天内持续重试扣款,所以这时是“流失风险”而不是“流失”。
DID_CHANGE_RENEWAL_STATUS + AUTO_RENEW_DISABLED 是关自动续订,用户当期还有效。把它当“已退订”直接停服务,是最容易挨投诉的一种处理。
十、什么时候根本不该用 IAP
- App 内解锁的虚拟内容(会员、道具、课程、去广告)在 iOS 上必须走 IAP,接第三方支付会被拒审,这不是技术选择。
- 实体商品、线下服务、打赏给内容创作者之外的实物交易,Apple 明确要求不能用 IAP 收,得走微信/支付宝/Stripe。
- 大陆地区的小程序/微信生态内的虚拟支付是另一套(微信侧 paySig + 平台侧规则),和 App Store 完全无关,别指望一套代码两端通用。
所以一个稍具规模的产品,付款链路通常是三条并行:iOS IAP、Android 各家商店/第三方、Web 端 Stripe 或微信支付宝。金额单位、订单状态机、退款语义各不相同,最好在建表那天就按渠道拆开,而不是塞进一张 orders 表里加一堆 if (channel == 'apple')。