苹果在App Store Server Notifications V2中改用了基于JWT的签名机制,每一条通知的payload和signedTransactionInfo都是标准的JWS结构。服务端如果还沿用V1时代基于共享密钥的做法,或者验证逻辑写得不够严谨,就很容易遇到签名验证失败的问题。这篇文章围绕JWKS公钥获取、x5c证书链验证、签名算法匹配这三个最常见的失败原因展开,逐层拆解验证流程并给出可直接落地的Swift代码。

一、签名验证失败的三种典型表现
先明确验证的目标:V2通知的响应体中,signedPayload是一个JWS(JWT Signature)字符串,格式为header.payload.signature三段Base64URL编码。header中除了alg和kid字段外,还携带一个x5c证书链数组。验证失败通常表现为以下三类。
第一类是JWKS公钥获取失败或kid对不上。验证方需要先从苹果的JWKS端点拉取公钥集合,再按header中的kid查找对应公钥。如果缓存过期或网络受限,找不到匹配的kid,验证自然失败。第二类是证书链验证不通过。苹果要求验证x5c证书链必须钉住(pin)Apple Root CA G3根证书,很多实现漏掉这一步,或者系统证书池里没有对应根证书,导致链校验直接报错。第三类是算法不匹配。苹果的V2通知统一使用ES256算法,如果你用了RS256的验签逻辑,或者JWT库默认算法与header声明不一致,就会抛出算法不匹配的异常。
排查时建议按顺序打日志:先打印header解析结果确认alg和kid,再确认JWKS拉取是否成功,最后看证书链校验的具体报错。定位到环节后,再针对性修复。
二、JWKS公钥获取与kid匹配的正确姿势
苹果的JWKS公钥可以通过官方端点获取:https://appleid.apple.com/auth/keys。注意这个端点服务于App Store服务器通知的验签场景,返回的是标准的JSON Web Key Set结构,包含kty、x5c、n(对EC算法是x和y)、kid、alg等字段。每个key对应苹果的一张签名证书,苹果可能会轮换密钥,所以不能把公钥硬编码到代码里。
正确的做法是:定期拉取JWKS并缓存(建议缓存时间不超过24小时,遇到未知kid时强制刷新一次),验证时按kid查找公钥。下面是Swift服务端的实现示例:
import Foundation
struct JWK: Codable {
let kty: String
let kid: String
let use: String?
let alg: String?
let n: String?
let e: String?
let x: String?
let y: String?
let crv: String?
let x5c: [String]?
}
struct JWKS: Codable {
let keys: [JWK]
}
final class JWKSProvider {
private let url = URL(string: "https://appleid.apple.com/auth/keys")!
private var cache: JWKS?
private var lastFetch: Date = .distantPast
func fetchJWKS(forceRefresh: Bool = false) async throws -> JWKS {
// 缓存有效期6小时,超过或强制刷新时重新拉取
if !forceRefresh, let cache = cache,
Date().timeIntervalSince(lastFetch) < 6 * 3600 {
return cache
}
let (data, _) = try await URLSession.shared.data(from: url)
let jwks = try JSONDecoder().decode(JWKS.self, from: data)
self.cache = jwks
self.lastFetch = Date()
return jwks
}
func key(forKid kid: String) async throws -> JWK {
let jwks = try await fetchJWKS()
if let key = jwks.keys.first(where: { $0.kid == kid }) {
return key
}
// 找不到kid时强制刷新一次,应对苹果密钥轮换
let refreshed = try await fetchJWKS(forceRefresh: true)
guard let key = refreshed.keys.first(where: { $0.kid == kid }) else {
throw NSError(domain: "JWKS", code: 404,
userInfo: [NSLocalizedDescriptionKey: "公钥集合中找不到kid: \(kid)"])
}
return key
}
}
这里有一个容易踩的坑:网络环境如果出不了海,直接请求苹果端点会超时,此时看到的错误可能是底层连接错误而不是验签错误,容易被误判为签名问题。建议把JWKS拉取失败的日志和验签失败的日志区分开。另外,JWKS的公钥可能会随证书轮换而变化,验证逻辑必须支持动态刷新,遇到未知kid时重试一次再下结论。
三、x5c证书链验证与根证书钉扎
除了JWKS方式,V2通知的header中还带有完整的x5c证书链:第一张是签名证书,中间是Apple WWDR CA证书,最后到Apple Root CA。苹果的官方要求是验证这个证书链时必须显式钉住Apple Root CA G3根证书,只信任这一条链,而不是依赖系统默认信任的所有根证书。
具体步骤是:从JWS header的x5c数组解析出证书,构建SecCertificate对象,用SecTrustEvaluateWithError做链校验,校验时通过SecPolicyCreateSSL或自定义anchor设置根证书。同时必须检查签名证书的OCSP状态是否被撤销。示例代码如下:
import Foundation
import Security
func extractCertificates(fromX5C x5c: [String]) throws -> [SecCertificate] {
var certs: [SecCertificate] = []
for pem in x5c {
guard let der = Data(base64Encoded: pem) else {
throw NSError(domain: "X5C", code: -1,
userInfo: [NSLocalizedDescriptionKey: "证书Base64解码失败"])
}
guard let cert = SecCertificateCreateWithData(nil, der as CFData) else {
throw NSError(domain: "X5C", code: -2,
userInfo: [NSLocalizedDescriptionKey: "证书对象创建失败"])
}
certs.append(cert)
}
return certs
}
// apple-root-cert.pem 需要提前打包进服务端,内容为Apple Root CA G3
func verifyCertificateChain(_ certs: [SecCertificate], rootPEM: String) throws -> Data {
guard let leaf = certs.first else {
throw NSError(domain: "Chain", code: -1,
userInfo: [NSLocalizedDescriptionKey: "证书链为空"])
}
// 从打包的PEM文件加载Apple根证书作为唯一信任锚点
let rootData = Data(rootPEM.utf8)
let rootCert = try extractCertificates(fromX5C: [rootData.base64EncodedString()])[0]
var trust: SecTrust?
let policy = SecPolicyCreateBasicX509()
let status = SecTrustCreateWithCertificates(certs as CFArray, policy, &trust)
guard status == errSecSuccess, let trust = trust else {
throw NSError(domain: "Chain", code: -3,
userInfo: [NSLocalizedDescriptionKey: "SecTrust创建失败"])
}
// 钉住Apple根证书:只信任这一张根证书
SecTrustSetAnchorCertificates(trust, [rootCert] as CFArray)
SecTrustSetAnchorCertificatesOnly(trust, true)
var error: Unmanaged<CFError>?
guard SecTrustEvaluateWithError(trust, &error) else {
throw error?.takeRetainedValue() ?? NSError(domain: "Chain", code: -4,
userInfo: [NSLocalizedDescriptionKey: "证书链验证失败"])
}
// 校验通过后,返回叶子证书的公钥用于验签
guard let publicKey = SecTrustCopyKey(trust) else {
throw NSError(domain: "Chain", code: -5,
userInfo: [NSLocalizedDescriptionKey: "无法提取公钥"])
}
var keyData = Data()
let copyStatus = withUnsafeMutablePointer(to: &keyData) { ptr in
SecItemExport(publicKey, SecFormatOpenSSL, [], ptr)
}
guard copyStatus == errSecSuccess else {
throw NSError(domain: "Chain", code: -6,
userInfo: [NSLocalizedDescriptionKey: "公钥导出失败"])
}
return keyData
}
两个高频踩坑点需要提醒。第一,根证书要下载正确的版本,Apple Root CA G3的SHA256指纹可以在苹果官方证书支持页面核对,用错G2或旧的WWDR G4证书会导致锚点不匹配。第二,OCSP检查在某些JWT库中默认关闭,苹果明确要求校验签名证书未被撤销,如果自己实现验签,别漏掉这一步,可以通过解析证书的OCSP扩展并请求苹果的OCSP服务来完成。
四、签名算法匹配与验签的落地实现
苹果V2通知的所有JWS统一使用ES256算法,即ECDSA over P-256 with SHA-256。header中的alg字段必须严格等于ES256,不要依赖JWT库的默认算法列表,更不要允许客户端传什么算法就验什么算法,这是经典的算法混淆攻击面。验签前先做白名单校验:
import Crypto
import Foundation
struct JWSHeader: Codable {
let alg: String
let kid: String
let x5c: [String]?
}
func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
while str.count % 4 != 0 { str += "=" }
return Data(base64Encoded: str)
}
func verifySignature(signedPayload: String, jwk: JWK) throws -> Bool {
let parts = signedPayload.split(separator: ".")
guard parts.count == 3 else {
throw NSError(domain: "JWS", code: -1,
userInfo: [NSLocalizedDescriptionKey: "JWS格式不合法"])
}
let headerData = try JSONDecoder().decode(
JWSHeader.self, from: base64URLDecode(String(parts[0])) ?? Data())
// 第一步:算法白名单校验,防止算法混淆
guard headerData.alg == "ES256", jwk.alg == "ES256" else {
throw NSError(domain: "JWS", code: -2,
userInfo: [NSLocalizedDescriptionKey: "算法不匹配,期望ES256,实际\(headerData.alg)"])
}
// 第二步:构造待验签内容 header.payload
guard let signedContent = (String(parts[0]) + "." + String(parts[1]))
.data(using: .utf8) else { return false }
// 第三步:解码JWK中的EC公钥(x、y为Base64URL编码的坐标)
guard let xData = base64URLDecode(jwk.x ?? ""),
let yData = base64URLDecode(jwk.y ?? ""),
jwk.crv == "P-256" else {
throw NSError(domain: "JWS", code: -3,
userInfo: [NSLocalizedDescriptionKey: "JWK参数不完整或曲线不匹配"])
}
// P-256未压缩公钥格式:0x04 + x(32字节) + y(32字节)
var rawKey = Data([0x04]) + xData + yData
let key = try P256.Signing.PublicKey(rawRepresentation: rawKey)
// 第四步:ES256签名为固定64字节的r||s拼接,直接使用
guard let sigData = base64URLDecode(String(parts[2])) else { return false }
let ecdsaSig = try P256.Signing.ECDSASignature(rawRepresentation: sigData)
return key.isValidSignature(ecdsaSig, for: SHA256.hash(data: signedContent))
}
注意ECDSA签名编码差异:JWS规范要求签名为64字节的r和s大端拼接,而DER编码是ASN.1结构。如果你用的库输出或期望的是DER格式,就会出现偶发验证失败,转换函数务必写对。此外,signedTransactionInfo和signedRenewalInfo是嵌套的JWS,同样要用相同的流程再验一次签,不要只验外层payload就信任内层数据。
最后建议把整条验证链封装成一个独立的中间件或工具类,日志覆盖JWKS拉取、kid查找、证书链校验、算法校验、签名比对五个环节。这样一旦线上出现验证失败,可以在一分钟内定位到具体环节,而不是面对一条笼统的验签失败异常反复猜测。验证通过后再解析交易数据并回写订阅状态,整个内购订阅的状态同步才算是真正可靠。