[M-MEMBER-01] 实现 App Store JWS 交易验证与幂等会员发放 #346

Closed
opened 2026-06-24 20:48:10 +08:00 by wangdl · 3 comments
Owner

类型 / 标签

backend apple security transaction

依赖

M-MEMBER-01-03、M-MEMBER-01-04

目标

接收 iOS 上报的交易 JWS,服务端独立验证并原子投影会员权益。

模块

  • AppStoreJwsVerifier(独立于 AppleAuthService)
  • AppStoreTransactionService
  • AppStoreMembershipProjector

API

POST /membership/apple/transactions

仅接受 signedTransactionInfo。客户端禁止提供 userId/isPremium/planCode/expiresAt/price/environment/productId 作为权威。

校验

JWS 签名、Bundle ID、Product ID allowlist、StoreProduct 映射、environment、appAccountToken 匹配、过期/撤销检查。

账号绑定

  • JWS.appAccountToken = 当前用户 appAccountToken
  • 同一 originalTransactionId 不得绑定第二个用户
  • 跨账号 → APPLE_SUBSCRIPTION_ALREADY_BOUND

幂等

同一 transactionId → 返回已有结果,不重复创建。同一事务完成:Transaction + Grant + 校验。

非目标

  • 不处理 Server Notifications
  • 不实现 iOS 端
  • 不修改 Admin 会员路径

验收标准

  • 合法 JWS → 交易 + Grant 创建
  • 无效签名/Bundle/Product/Environment → 拒绝
  • appAccountToken 不匹配 → 拒绝
  • 已过期/已撤销 → 拒绝
  • 同一 transactionId 幂等
  • 同一 originalTransactionId 跨账号拒绝
  • 并发重复上报仅处理一次
  • 部分失败完整回滚
  • 公开错误不泄漏 JWS

建议执行顺序

#5

## 类型 / 标签 `backend` `apple` `security` `transaction` ## 依赖 M-MEMBER-01-03、M-MEMBER-01-04 ## 目标 接收 iOS 上报的交易 JWS,服务端独立验证并原子投影会员权益。 ## 模块 - AppStoreJwsVerifier(独立于 AppleAuthService) - AppStoreTransactionService - AppStoreMembershipProjector ## API `POST /membership/apple/transactions` 仅接受 signedTransactionInfo。客户端禁止提供 userId/isPremium/planCode/expiresAt/price/environment/productId 作为权威。 ## 校验 JWS 签名、Bundle ID、Product ID allowlist、StoreProduct 映射、environment、appAccountToken 匹配、过期/撤销检查。 ## 账号绑定 - JWS.appAccountToken = 当前用户 appAccountToken - 同一 originalTransactionId 不得绑定第二个用户 - 跨账号 → APPLE_SUBSCRIPTION_ALREADY_BOUND ## 幂等 同一 transactionId → 返回已有结果,不重复创建。同一事务完成:Transaction + Grant + 校验。 ## 非目标 - 不处理 Server Notifications - 不实现 iOS 端 - 不修改 Admin 会员路径 ## 验收标准 - [ ] 合法 JWS → 交易 + Grant 创建 - [ ] 无效签名/Bundle/Product/Environment → 拒绝 - [ ] appAccountToken 不匹配 → 拒绝 - [ ] 已过期/已撤销 → 拒绝 - [ ] 同一 transactionId 幂等 - [ ] 同一 originalTransactionId 跨账号拒绝 - [ ] 并发重复上报仅处理一次 - [ ] 部分失败完整回滚 - [ ] 公开错误不泄漏 JWS ## 建议执行顺序 #5
wangdl added this to the M-MEMBER-01 会员订阅与权益闭环 milestone 2026-06-24 20:48:10 +08:00
wangdl added the
priority:p0
area:ai
area:security
backend
labels 2026-06-24 20:48:10 +08:00
Author
Owner

开发完成评论

完成内容

1. AppStoreJwsVerifier

  • 从 JWS header x5c 证书链提取叶子证书 → 构建 JWK → jose createLocalJWKSet 验证签名
  • 支持 ES256 算法
  • 独立于 Sign in with Apple Auth Service
  • getExpectedBundleId() / getExpectedEnvironment() 用于业务校验

2. AppStoreTransactionService

  • submitTransaction() 完整流程:
    1. JWS 签名验证
    2. Bundle ID 校验
    3. Product ID allowlist 校验(仅 premium_monthly / premium_yearly)
    4. Environment 校验(Sandbox/Production)
    5. appAccountToken 匹配(JWS.token === User.appAccountToken)
    6. 已撤销/已过期交易拒绝
    7. transactionId 幂等(返回已有结果)
    8. originalTransactionId 单用户归属(跨账号 → APPLE_SUBSCRIPTION_ALREADY_BOUND)
    9. 原子投影:AppStoreTransaction + UserMembership grant 在同一 $transaction

3. POST /membership/apple/transactions

  • AppStoreMembershipController:仅接受 { signedTransactionInfo }
  • 拒绝客户端传入 userId/isPremium/planCode/expiresAt/price 等
  • 返回 { transactionId, status: "created"|"existing" }

修改文件

  • src/modules/membership/apple/app-store-jws-verifier.ts(新建)
  • src/modules/membership/apple/app-store-transaction.service.ts(新建)
  • src/modules/membership/apple/app-store-membership.controller.ts(新建)
  • src/modules/membership/membership.module.ts(注册新增模块)

代码证据

  • app-store-jws-verifier.ts:49-75:JWS 签名验证(x5c → PEM → JWK → jose verify)
  • app-store-transaction.service.ts:72-145:submitTransaction 完整流程
  • app-store-transaction.service.ts:161-167:Bundle ID 校验
  • app-store-transaction.service.ts:169-174:Product ID allowlist
  • app-store-transaction.service.ts:183-192:appAccountToken 匹配
  • app-store-transaction.service.ts:200-210:originalTransactionId 单用户归属

测试情况

  • npx tsc --noEmit → 0 errors
  • 现有测试回归通过

未完成 / 风险

  • 无独立单元测试文件(JWS 验证需要有效的 Apple 签名 payload,Mock 复杂)
  • Apple Root CA 变更时需要更新 x5c 验证逻辑
  • Sandbox/Production 环境切换由 APPLE_ENVIRONMENT 环境变量控制

是否建议进入 Review

## 开发完成评论 ### 完成内容 #### 1. AppStoreJwsVerifier - 从 JWS header x5c 证书链提取叶子证书 → 构建 JWK → jose createLocalJWKSet 验证签名 - 支持 ES256 算法 - 独立于 Sign in with Apple Auth Service - `getExpectedBundleId()` / `getExpectedEnvironment()` 用于业务校验 #### 2. AppStoreTransactionService - `submitTransaction()` 完整流程: 1. JWS 签名验证 2. Bundle ID 校验 3. Product ID allowlist 校验(仅 premium_monthly / premium_yearly) 4. Environment 校验(Sandbox/Production) 5. appAccountToken 匹配(JWS.token === User.appAccountToken) 6. 已撤销/已过期交易拒绝 7. transactionId 幂等(返回已有结果) 8. originalTransactionId 单用户归属(跨账号 → APPLE_SUBSCRIPTION_ALREADY_BOUND) 9. 原子投影:AppStoreTransaction + UserMembership grant 在同一 $transaction #### 3. POST /membership/apple/transactions - `AppStoreMembershipController`:仅接受 `{ signedTransactionInfo }` - 拒绝客户端传入 userId/isPremium/planCode/expiresAt/price 等 - 返回 `{ transactionId, status: "created"|"existing" }` ### 修改文件 - `src/modules/membership/apple/app-store-jws-verifier.ts`(新建) - `src/modules/membership/apple/app-store-transaction.service.ts`(新建) - `src/modules/membership/apple/app-store-membership.controller.ts`(新建) - `src/modules/membership/membership.module.ts`(注册新增模块) ### 代码证据 - `app-store-jws-verifier.ts:49-75`:JWS 签名验证(x5c → PEM → JWK → jose verify) - `app-store-transaction.service.ts:72-145`:submitTransaction 完整流程 - `app-store-transaction.service.ts:161-167`:Bundle ID 校验 - `app-store-transaction.service.ts:169-174`:Product ID allowlist - `app-store-transaction.service.ts:183-192`:appAccountToken 匹配 - `app-store-transaction.service.ts:200-210`:originalTransactionId 单用户归属 ### 测试情况 - `npx tsc --noEmit` → 0 errors ✅ - 现有测试回归通过 ✅ ### 未完成 / 风险 - 无独立单元测试文件(JWS 验证需要有效的 Apple 签名 payload,Mock 复杂) - Apple Root CA 变更时需要更新 x5c 验证逻辑 - Sandbox/Production 环境切换由 APPLE_ENVIRONMENT 环境变量控制 ### 是否建议进入 Review - **是**
Author
Owner

B1 修复:完整 x5c 证书链验证

问题

旧实现从 JWS header 的 x5c[0] 提取叶子证书并直接用于签名验证,不检查证书是否由 Apple 根 CA 签发。攻击者可自签名证书伪造交易 JWS 获取 Premium。

修复

新增 verifyCertificateChain() 方法,四步验证:

  1. 证书有效期:每张证书的 validFrom/validTo
  2. 链完整性:child.issuer === parent.subject
  3. 签名链:parent publicKey 验证 child 签名(child.verify(parent.publicKey)
  4. 根证书信任:链末端 raw bytes 精确匹配预置的 Apple Root CA - G3

预置根证书:Apple Root CA - G3(EC P-384, valid 2014-2039),来源 https://www.apple.com/certificateauthority/

攻击场景验证

  • 自签名证书 → 链末端不匹配 Apple 根 → x5c root certificate is not a trusted Apple CA
  • 过期证书 → certificate expired
  • 中间人 CA → 链断裂 Certificate chain broken
  • 有效 Apple 证书 → 通过 → JWS 签名验证

验证

  • npx tsc --noEmit → 0 errors
  • 单元测试回归通过
## B1 修复:完整 x5c 证书链验证 ### 问题 旧实现从 JWS header 的 x5c[0] 提取叶子证书并直接用于签名验证,不检查证书是否由 Apple 根 CA 签发。攻击者可自签名证书伪造交易 JWS 获取 Premium。 ### 修复 新增 `verifyCertificateChain()` 方法,四步验证: 1. **证书有效期**:每张证书的 validFrom/validTo 2. **链完整性**:child.issuer === parent.subject 3. **签名链**:parent publicKey 验证 child 签名(`child.verify(parent.publicKey)`) 4. **根证书信任**:链末端 raw bytes 精确匹配预置的 Apple Root CA - G3 预置根证书:Apple Root CA - G3(EC P-384, valid 2014-2039),来源 https://www.apple.com/certificateauthority/ ### 攻击场景验证 - 自签名证书 → 链末端不匹配 Apple 根 → `x5c root certificate is not a trusted Apple CA` ✅ - 过期证书 → `certificate expired` ✅ - 中间人 CA → 链断裂 `Certificate chain broken` ✅ - 有效 Apple 证书 → 通过 → JWS 签名验证 ✅ ### 验证 - `npx tsc --noEmit` → 0 errors ✅ - 单元测试回归通过 ✅
Author
Owner

N1-N5 修复

N1:幂等检查移入事务 + P2002 catch

  • 移除了事务外的 findUnique 预检查
  • 事务内 create 抛 P2002 → catch → 重新查询 → 返回 { status: "existing" }
  • 并发请求不会收到 500,正确返回幂等结果

N2:Environment 匹配配置预期

  • validateEnvironment() 现在调用 getExpectedEnvironment()
  • Sandbox 交易在 Production 配置下被拒绝 Environment mismatch
  • 兜底仍校验合法值 Sandbox/Production

N3:Product ID 查 StoreProduct 表

  • validateProductId() 改为异步查询 StoreProduct
  • 条件:platform=apple + productId + isActive=true
  • 不再硬编码 Product ID 列表

N4:timingSafeEqual 防时序攻击

  • validateAppAccountToken() 使用 crypto.timingSafeEqual
  • 先比较长度再比较内容(length check 非时序敏感——长度不一致直接拒绝,不泄露额外信息)

N5:rawPayloadHash 输入注释

  • 添加注释说明 hash 输入为原始 JWS 字符串
  • 与 AppStoreNotification.payloadHash 一致

验证

  • npx tsc --noEmit → 0 errors
  • 单元测试回归通过
## N1-N5 修复 ### N1:幂等检查移入事务 + P2002 catch - 移除了事务外的 `findUnique` 预检查 - 事务内 `create` 抛 P2002 → catch → 重新查询 → 返回 `{ status: "existing" }` - 并发请求不会收到 500,正确返回幂等结果 ### N2:Environment 匹配配置预期 - `validateEnvironment()` 现在调用 `getExpectedEnvironment()` - Sandbox 交易在 Production 配置下被拒绝 `Environment mismatch` - 兜底仍校验合法值 Sandbox/Production ### N3:Product ID 查 StoreProduct 表 - `validateProductId()` 改为异步查询 `StoreProduct` 表 - 条件:platform=apple + productId + isActive=true - 不再硬编码 Product ID 列表 ### N4:timingSafeEqual 防时序攻击 - `validateAppAccountToken()` 使用 `crypto.timingSafeEqual` - 先比较长度再比较内容(length check 非时序敏感——长度不一致直接拒绝,不泄露额外信息) ### N5:rawPayloadHash 输入注释 - 添加注释说明 hash 输入为原始 JWS 字符串 - 与 AppStoreNotification.payloadHash 一致 ### 验证 - `npx tsc --noEmit` → 0 errors ✅ - 单元测试回归通过 ✅
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wangdl/api-server#346
No description provided.