开发团队在接入苹果订阅服务后,服务端会收到苹果发送的订阅状态变更通知(Server Notifications V2),这些通知都是经过ES256算法签名的JWT。验证签名时经常遇到algorithm mismatch或者invalid signature,反复检查证书和公钥却找不到原因。实际上,问题通常出在两个层面:一是ES256签名验证的细节没做对,比如签名输入用了错误的内容,或者签名格式没有从raw转换成库要求的DER;二是对JWT头部和载荷的格式要求理解不完整,比如头部kid没有正确匹配JWKS中的密钥,或者载荷里内嵌的JWT没有被递归验证。下面先把整体流程梳理清楚,再针对每个坑给出解决方案。

从整体看,苹果的通知先经过Base64URL编码分成三段,签名算法固定为ES256。验证方需要先拿header里的kid去苹果JWKS接口找到对应公钥,然后用这个公钥对header和payload的原始字符串做签名验证。这个过程涉及几个关键细节,任何一个环节出错都会导致验证失败。
签名验证失败的常见原因定位
收到苹果通知后,如果签名验证返回错误,第一步不是怀疑苹果证书有问题,而是先把收到的JWT按点号拆开,分别查看header和payload的原始内容。很多失败案例中,开发者使用了错误的算法标识,例如把ES256写成RS256,或者使用了不支持ES256的JWT库。苹果的通知头部alg字段明确写着ES256,任何用RS256、HS256去验证的尝试都会直接失败。
另一个高频错误是Base64URL解码处理得不严谨。Base64URL和普通Base64的区别在于要把+和/替换为-和_,并去掉末尾的=填充。很多语言的标准Base64解码器无法直接处理Base64URL字符串,如果忘了替换字符或者补充填充,解码出来的header和payload就是乱码。即使header解析成功,kid也可能提取错误,导致后续公钥匹配不上。
最难发现的错误是签名输入使用了错误的内容。JWT的签名计算对象是header和payload两段的原始Base64URL字符串,中间用点号连接,也就是encodedHeader + "." + encodedPayload。有些人误以为应该对解码后的JSON字符串做签名,这会导致签名永远验证失败。验证时必须原样保留JWT中的两段字符串,不做任何解码和重新编码。
ES256签名算法与密钥构造
ES256是ECDSA算法在P-256曲线上的实现,摘要函数使用SHA-256。苹果在JWKS(JSON Web Key Set)中发布的公钥字段包括kty为EC、crv为P-256、x和y分别为椭圆曲线点的坐标。开发者需要根据这两个坐标构造出P-256公钥,才能进行签名验证。不同语言和库的构造方式略有差异,但核心都是把x和y作为大端序字节数组传给EC公钥构造函数。
获取JWKS的地址是官方固定的:https://appleid.apple.com/auth/keys。这个接口返回一个JSON对象,包含一个keys数组,每一项都有一个kid字段。验证时,从JWT头部提取kid,然后在keys数组中找到匹配项,再用该项的x和y构造公钥。如果kid没有匹配上,说明JWKS可能已经更新,需要重新拉取并缓存。苹果会不定期轮换密钥,所以缓存时间不要过长,建议每小时刷新一次。
签名值的格式是另一个需要特别注意的细节。JWT中的签名部分是Base64URL编码后的64字节数据,这64字节是ECDSA签名的raw格式,即R值(前32字节)和S值(后32字节)直接拼接。很多加密库的验证函数期望签名输入是DER编码格式,而不是raw格式。如果你把raw签名直接传给这类库,就会得到签名无效的错误。解决办法可以是用支持raw格式的库(如Node.js的jose库),或者手动将raw转换成DER。
下面是一段将raw签名转换为DER格式的Node.js代码,方便使用内置crypto模块验证:
function rawToDer(raw) {
function integerToDer(integerBuffer) {
let hex = integerBuffer.toString('hex');
if ((parseInt(hex[0], 16) & 0x8) === 0x8) {
hex = '00' + hex;
}
const len = Math.ceil(hex.length / 2);
return Buffer.concat([
Buffer.from([0x02, len]),
Buffer.from(hex, 'hex')
]);
}
if (raw.length !== 64) {
throw new Error('Invalid raw signature length');
}
const r = raw.slice(0, 32);
const s = raw.slice(32, 64);
const der = Buffer.concat([
Buffer.from([0x30]),
Buffer.from([raw.length + 2]),
integerToDer(r),
integerToDer(s)
]);
return der;
}
这段代码中,integerToDer负责把32字节的R或S转换成DER整数格式,如果最高位为1还需要在前面补一个0x00避免被当作负数。最终拼接成DER序列。使用内置crypto的verify方法时,算法参数传sha256,密钥使用P-256公钥,签名传入转换后的DER即可。
JWT头部与载荷的格式要求
苹果通知的JWT头部是一个简单的JSON对象,至少包含两个字段:alg和kid。alg的值固定为ES256,表示签名算法;kid是一个字符串,用于标识本次签名使用的密钥,必须与JWKS中的某个kid完全匹配。头部经过Base64URL编码后作为JWT的第一段,如果头部缺少kid或者kid不匹配,验证会直接失败。
载荷部分同样有明确的格式要求。Server Notifications V2的载荷包含以下核心字段:notificationType、subtype、notificationUUID、data、version、signedDate。其中version目前固定为v2,signedDate是苹果服务器签名时间的毫秒时间戳,可以用来做防重放校验,例如只接受5分钟内签发的通知。data字段内部还包含appAppleId、bundleId、environment以及两个重要的JWT字符串:signedTransactionInfo和signedRenewalInfo。
这里有一个容易忽略的坑:验证完外层JWT的签名后,data里的两个内层JWT并不会自动被验证。它们同样是ES256签名的JWT,需要分别提取出来,用相同的方式再做一次签名验证。内层JWT的载荷里存储了具体的交易信息(如原始交易ID、产品ID、购买时间)和续订信息(如自动续订状态、到期时间)。如果不验证内层签名,就可能接受伪造的通知数据。
还要注意载荷的字段类型。例如signedDate是数字,不是字符串;notificationType是枚举字符串,可能的值包括SUBSCRIBED、DID_RENEW、EXPIRED等。在解析时不要假设所有字段都存在,苹果可能根据通知类型省略subtype。另外,所有Base64URL解码操作都应该使用支持URL安全字符的库函数,避免手动替换字符时遗漏。
完整的服务端验证实现与建议
对于Node.js环境,最简单可靠的方式是使用jose库,它原生支持ES256算法和raw签名格式,不需要手动转换DER,也不用手动处理Base64URL。下面是一个完整的验证函数示例:
const jose = require('jose');
async function getApplePublicKey(kid) {
const response = await fetch('https://appleid.apple.com/auth/keys');
const jwks = await response.json();
const key = jwks.keys.find(k => k.kid === kid);
if (!key) {
throw new Error('No matching key found for kid: ' + kid);
}
return jose.importJWK(key, 'ES256');
}
async function verifyAppleNotification(jwt) {
const { header } = jose.decodeProtectedHeader(jwt);
const publicKey = await getApplePublicKey(header.kid);
const { payload } = await jose.jwtVerify(jwt, publicKey, {
algorithms: ['ES256']
});
return payload;
}
注意代码中jose.importJWK可以接收JWKS中的单个密钥对象并构造公钥。jwtVerify内部会自动使用ES256算法和raw签名格式,开发者无需关心DER转换。如果需要对内层JWT也做验证,可以递归调用verifyAppleNotification,但要注意内层JWT的kid可能与外层不同,需要重新获取公钥。
对于需要自己控制验证流程的场景,可以使用Node.js内置的crypto模块。先手动拆分JWT,提取header和payload,然后从JWKS构造P-256公钥,最后用crypto.verify验证签名。这种方式更加透明,但需要处理Base64URL解码、raw到DER转换等细节。两种方式各有取舍:用jose可以快速上线且不易出错,但隐去了底层细节;手动实现有助于深入理解验证机制,排查疑难问题。
最后还有两个建议:第一,对signedDate做时间窗口校验,防止重放旧通知;第二,缓存从JWKS获取的公钥,但要设置合理的过期时间,并在遇到kid不匹配时强制刷新。苹果的密钥轮换周期较长,但公钥接口是公开的,可以随时拉取。完成这些处理后,订阅状态变更通知的签名验证就能稳定运行,不会再被算法不匹配的问题困扰。