PHP处理苹果支付V2多环境公钥验证注意事项

Luna
Luna
发布时间:2026-09-20| 4人浏览过
原创

PHP接收苹果 App Store Server Notifications V2 时,真正需要验证的不是一把固定的“Sandbox 公钥”或“Production 公钥”,而是苹果放在 JWS Header 的 x5c 证书链。正确流程是:解析 JWS 结构、使用本地可信的 Apple Root CA 验证证书链、从叶子证书取得公钥验证 ES256 签名,签名成功后再检查 environment、bundleId 和 Production 环境下的 appAppleId。

这几个步骤不能随意调换。尤其不能因为 Base64URL 解码后能看到 JSON,就直接相信其中的交易状态或环境字段。V2 的 signedPayload 本质上是 JWS,苹果官方文档明确说明它由 Header、Payload 和 Signature 三部分组成,并使用 Header 中的证书信息验证签名。

一、先弄清楚V2到底在验证什么

App Store Server Notifications V1 返回的是普通 JSON 结构,而 V2 的核心字段变成了:

{
    "signedPayload": "xxxxx.yyyyy.zzzzz"
}

signedPayload 中三个以英文句点分隔的部分分别是:

  • Header:描述签名算法,并包含 x5c X.509 证书链。
  • Payload:通知类型、环境、应用信息以及其他业务数据。
  • Signature:苹果针对前两部分生成的数字签名。

苹果文档目前描述的 JWS 证书链顺序是:叶子签名证书、Apple 中间证书、Apple Root 证书。真正用于验证这条 JWS 签名的是第一张叶子证书中的公钥,而这张证书本身又必须能够沿证书链回溯到你服务器预先信任的 Apple Root CA。

SVG 配图 1
图1:V2的可信关系由JWS签名和Apple证书链共同建立,本地保存的Apple Root CA才是信任起点。

二、Sandbox和Production并不是简单换两把公钥

处理多环境时最容易出现的误区,是设计成:

if ($env === 'Sandbox') {
    $publicKey = $sandboxPublicKey;
} else {
    $publicKey = $productionPublicKey;
}

对于 App Store Server Notifications V2,这种思路并不准确。服务器通常不应该维护一把永久不变的“苹果交易公钥”,而是从每次 JWS Header 的 x5c 中取得叶子证书,再验证这条证书链确实归属于受信任的 Apple Root CA。

Sandbox 与 Production 真正必须分开的,是业务身份校验。苹果官方 Server Library 的验证逻辑会比较通知中的环境与验证器指定的环境,并检查 bundleId;Production 环境还要求提供并检查 appAppleId。官方实现明确要求 Production 使用 appAppleId,而 Sandbox 可以不提供。

同时,苹果文档说明 Sandbox 的通知数据中通常不存在 appAppleId,因此不能把“Production 必须存在 appAppleId”的规则机械套到 Sandbox。

检查项 Sandbox Production
JWS签名 必须验证 必须验证
Apple证书链 必须验证 必须验证
bundleId 必须与自己的应用匹配 必须与自己的应用匹配
environment 必须为 Sandbox 必须为 Production
appAppleId 通常不作为必填校验项 应与 App Store 中的应用 Apple ID 匹配
SVG 配图 2
图2:应先完成密码学验证,再使用已经可信的Payload判断Sandbox或Production环境。

三、准备服务器上的Apple根证书

验证时最重要的一条原则是:信任锚必须来自服务器自己的可信配置,而不能来自客户端提交的 JWS。

虽然 x5c 中会带上证书链,甚至包含 Apple Root 证书,但不能因为对方提交了一张名叫“Apple Root”的证书就自动信任它。攻击者完全可以构造自己的根证书、叶子证书和 JWS,如果服务器直接信任请求里的最后一张证书,整个验证过程就失去了意义。

正确做法是从 Apple PKI 获取根证书,将其随服务器配置部署,例如:

/var/www/app/config/apple-certs/
├── AppleRootCA-G2.cer
└── AppleRootCA-G3.cer

实际部署哪些根证书,应以 Apple PKI 当前公布的 Root Certificates 为准,而不是从某篇旧教程复制一组长期不更新的文件名。苹果官方 Server Library 同样要求调用方预先加载 Apple Root Certificates,作为 SignedDataVerifier 的信任根。

注意:如果下载的是 DER 格式的 .cer,PHP/OpenSSL 使用前可以转换为 PEM;不要把 App Store Connect 下载的 .p8 API 私钥当成通知验签公钥。.p8 是你调用 App Store Server API 时用于生成 JWT 的开发者私钥,与苹果向你发送通知时的 JWS 验签不是一回事。

四、PHP解析JWS时先处理Base64URL

JWS 使用的是 Base64URL,而普通 base64_decode() 面对 -、_ 和省略的补位字符时不能直接照搬。建议单独封装:

<?php

function base64UrlDecode(string $value): string
{
    $value = strtr($value, '-_', '+/');
    $padding = strlen($value) % 4;

    if ($padding !== 0) {
        $value .= str_repeat('=', 4 - $padding);
    }

    $decoded = base64_decode($value, true);

    if ($decoded === false) {
        throw new RuntimeException('Base64URL 解码失败');
    }

    return $decoded;
}

然后检查 signedPayload 必须是三段:

$parts = explode('.', $signedPayload);

if (count($parts) !== 3) {
    throw new RuntimeException('signedPayload 不是合法的 JWS');
}

[$encodedHeader, $encodedPayload, $encodedSignature] = $parts;

$header = json_decode(
    base64UrlDecode($encodedHeader),
    true,
    512,
    JSON_THROW_ON_ERROR
);

$payload = json_decode(
    base64UrlDecode($encodedPayload),
    true,
    512,
    JSON_THROW_ON_ERROR
);

这里虽然已经可以打印出 $payload,但它仍然只是“尚未验证的输入”。在 openssl_verify() 返回成功之前,不应该根据其中的交易状态发货、延长会员时间,也不应该拿里面的 environment 去选择一个可信根。

五、检查alg和x5c,不能允许算法由请求随意决定

苹果用于这类签名数据的算法是 ES256。苹果在介绍 App Store Server Library 的技术说明中也说明其 JWS Header 使用 ES256,并通过 x5c 计算和验证签名公钥。

因此PHP端不要写成下面这种形式:

// 不建议
$algorithm = $header['alg'];
openssl_verify($data, $signature, $publicKey, $algorithm);

更安全的处理是直接建立允许列表:

if (($header['alg'] ?? null) !== 'ES256') {
    throw new RuntimeException('不支持的 JWS 签名算法');
}

if (
    !isset($header['x5c']) ||
    !is_array($header['x5c']) ||
    count($header['x5c']) < 2
) {
    throw new RuntimeException('JWS 缺少有效的 x5c 证书链');
}

这样即使攻击者篡改 Header,也不能让你的服务自动切换到一个更弱、甚至根本不验证签名的算法。

六、把x5c证书转换成PHP/OpenSSL可用的PEM

x5c 中每个元素是证书 DER 数据的 Base64 表示,不是 Base64URL。可以这样转换:

function x5cToPem(string $certificate): string
{
    $der = base64_decode($certificate, true);

    if ($der === false) {
        throw new RuntimeException('x5c 证书 Base64 解码失败');
    }

    return "-----BEGIN CERTIFICATE-----\n"
        . chunk_split(base64_encode($der), 64, "\n")
        . "-----END CERTIFICATE-----\n";
}

$certificates = array_map(
    static fn(string $cert): string => x5cToPem($cert),
    $header['x5c']
);

$leafCertificate = $certificates[0];

这里的 $leafCertificate 才是稍后用于取得签名公钥的证书,但在使用其公钥之前,要先确认它能够连接到服务器自己的 Apple Root CA。

七、先验证证书链,再验证JWS签名

下面给出一种使用 PHP OpenSSL 扩展完成基础证书链校验的实现。假设服务器已经准备了可信 Apple Root CA 文件。

function verifyAppleCertificateChain(
    array $certificates,
    array $trustedRootFiles
): void {
    if (count($certificates) < 2) {
        throw new RuntimeException('证书链长度异常');
    }

    $leafCertificate = $certificates[0];

    // x5c 中除叶子证书外的证书只作为 untrusted chain 使用。
    // 即使其中包含 Root,也不会把它直接作为服务器的信任锚。
    $untrustedChain = implode(
        "\n",
        array_slice($certificates, 1)
    );

    $tempFile = tempnam(sys_get_temp_dir(), 'apple-chain-');

    if ($tempFile === false) {
        throw new RuntimeException('无法创建临时证书文件');
    }

    try {
        if (file_put_contents($tempFile, $untrustedChain) === false) {
            throw new RuntimeException('无法写入临时证书链');
        }

        $result = openssl_x509_checkpurpose(
            $leafCertificate,
            X509_PURPOSE_ANY,
            $trustedRootFiles,
            $tempFile
        );

        if ($result !== true && $result !== 1) {
            throw new RuntimeException('Apple x5c 证书链验证失败');
        }
    } finally {
        @unlink($tempFile);
    }
}

调用时:

$trustedRoots = [
    '/var/www/app/config/apple-certs/AppleRootCA-G2.pem',
    '/var/www/app/config/apple-certs/AppleRootCA-G3.pem',
];

verifyAppleCertificateChain(
    $certificates,
    $trustedRoots
);
这里最关键的安全点:传入 $trustedRoots 的证书来自服务器本地配置;请求里的 x5c 只参与“待验证的证书链”,不能自动加入可信根列表。

八、ES256还有一个PHP常见坑:JWS签名不是OpenSSL直接要的DER格式

即使证书链完全正确,很多 PHP 实现仍然会卡在 openssl_verify()。原因在于 JWS 的 ES256 Signature 使用固定长度的原始 ECDSA R || S 格式,而 OpenSSL 通常要求 ASN.1 DER 编码的 ECDSA 签名。

ES256 使用 P-256,因此原始签名通常由两个32字节整数组成:

R:32 bytes
S:32 bytes

最终 JWS Signature:
R || S = 64 bytes

可以增加一个转换函数:

function derEncodeInteger(string $value): string
{
    $value = ltrim($value, "\x00");

    if ($value === '') {
        $value = "\x00";
    }

    if ((ord($value[0]) & 0x80) !== 0) {
        $value = "\x00" . $value;
    }

    return "\x02" . chr(strlen($value)) . $value;
}

function joseEs256SignatureToDer(string $signature): string
{
    if (strlen($signature) !== 64) {
        throw new RuntimeException('ES256 签名长度不是 64 字节');
    }

    $r = substr($signature, 0, 32);
    $s = substr($signature, 32, 32);

    $sequence = derEncodeInteger($r)
        . derEncodeInteger($s);

    return "\x30" . chr(strlen($sequence)) . $sequence;
}

之后才进入真正的 JWS 签名验证:

$publicKey = openssl_pkey_get_public($leafCertificate);

if ($publicKey === false) {
    throw new RuntimeException('无法读取叶子证书公钥');
}

$rawSignature = base64UrlDecode($encodedSignature);
$derSignature = joseEs256SignatureToDer($rawSignature);

$signingInput = $encodedHeader . '.' . $encodedPayload;

$verified = openssl_verify(
    $signingInput,
    $derSignature,
    $publicKey,
    OPENSSL_ALGO_SHA256
);

if ($verified !== 1) {
    throw new RuntimeException('Apple JWS 签名验证失败');
}

注意参与签名计算的是原始的Base64URL Header + 点号 + 原始Base64URL Payload,不是你解码、重新 json_encode() 后的字符串。JSON键顺序、空格或转义形式发生变化都会得到不同的字节序列。

九、签名通过以后,再判断环境

当证书链和 JWS 签名都成功以后,Payload 才进入可信范围。对大多数普通内购通知,环境和应用信息位于 data 中:

{
    "notificationType": "DID_RENEW",
    "data": {
        "bundleId": "com.example.app",
        "environment": "Production",
        "appAppleId": 123456789
    }
}

以上仅用于说明字段关系,不代表某一笔真实通知。

需要注意,V2 并非所有通知类型都一定使用 data。苹果当前的结构中,data、appData、summary 和 externalPurchaseToken 是互斥字段,不同通知类型可能出现不同对象。

因此,不建议只写:

$environment = $payload['data']['environment'];

可以封装成统一方法:

function extractNotificationContext(array $payload): array
{
    foreach (['data', 'appData', 'summary'] as $key) {
        if (isset($payload[$key]) && is_array($payload[$key])) {
            return [
                'environment' => $payload[$key]['environment'] ?? null,
                'bundleId' => $payload[$key]['bundleId'] ?? null,
                'appAppleId' => $payload[$key]['appAppleId'] ?? null,
            ];
        }
    }

    if (
        isset($payload['externalPurchaseToken']) &&
        is_array($payload['externalPurchaseToken'])
    ) {
        $data = $payload['externalPurchaseToken'];
        $externalPurchaseId = $data['externalPurchaseId'] ?? '';

        return [
            'environment' => str_starts_with(
                $externalPurchaseId,
                'SANDBOX'
            ) ? 'Sandbox' : 'Production',

            'bundleId' => $data['bundleId'] ?? null,
            'appAppleId' => $data['appAppleId'] ?? null,
        ];
    }

    throw new RuntimeException(
        '通知中没有可识别的应用环境信息'
    );
}

Apple 官方 Server Library 当前处理通知时同样会针对 data、summary、externalPurchaseToken 和 appData 分别提取应用及环境信息,而不是假定所有通知都只有 data。

SVG 配图 3
图3:双入口可以预先限定期望环境;单入口也能使用,但环境路由必须发生在签名验证之后。

十、建议把Sandbox和Production配置明确分开

如果使用两个通知URL,可以在进入验证函数时就明确告诉它当前接口期望哪个环境:

$appleConfig = [
    'Sandbox' => [
        'bundleId' => 'com.example.app',
        'appAppleId' => null,
    ],

    'Production' => [
        'bundleId' => 'com.example.app',
        'appAppleId' => 123456789,
    ],
];

环境和应用身份校验可以写成:

function validateApplicationContext(
    array $context,
    string $expectedEnvironment,
    array $config
): void {
    if ($context['environment'] !== $expectedEnvironment) {
        throw new RuntimeException(
            '通知环境与当前接口环境不匹配'
        );
    }

    if ($context['bundleId'] !== $config['bundleId']) {
        throw new RuntimeException(
            'bundleId 不匹配'
        );
    }

    if ($expectedEnvironment === 'Production') {
        if (
            $context['appAppleId'] === null ||
            (string)$context['appAppleId']
                !== (string)$config['appAppleId']
        ) {
            throw new RuntimeException(
                'Production appAppleId 不匹配'
            );
        }
    }
}

假如 Production 通知误发到了 Sandbox 接口,即使它的苹果签名完全正确,也应该被拒绝。因为“签名是真的”只能证明数据来自苹果,并不能证明它属于你当前这个应用、当前这套环境和当前这条业务流程。

十一、一个可以串起来理解的PHP验证流程

把前面的步骤组合后,主流程可以整理为:

<?php

function verifyAppleV2Notification(
    string $signedPayload,
    string $expectedEnvironment,
    array $environmentConfig,
    array $trustedRootFiles
): array {
    /*
     * 1. 拆分 JWS
     */
    $parts = explode('.', $signedPayload);

    if (count($parts) !== 3) {
        throw new RuntimeException('非法 JWS 结构');
    }

    [$encodedHeader, $encodedPayload, $encodedSignature] = $parts;

    /*
     * 2. 解码 Header
     */
    $header = json_decode(
        base64UrlDecode($encodedHeader),
        true,
        512,
        JSON_THROW_ON_ERROR
    );

    if (($header['alg'] ?? null) !== 'ES256') {
        throw new RuntimeException('只允许 ES256');
    }

    if (
        empty($header['x5c']) ||
        !is_array($header['x5c'])
    ) {
        throw new RuntimeException('缺少 x5c');
    }

    /*
     * 3. 构建证书链
     */
    $certificates = array_map(
        static fn(string $cert): string => x5cToPem($cert),
        $header['x5c']
    );

    verifyAppleCertificateChain(
        $certificates,
        $trustedRootFiles
    );

    /*
     * 4. 使用叶子证书公钥验证 ES256
     */
    $publicKey = openssl_pkey_get_public($certificates[0]);

    if ($publicKey === false) {
        throw new RuntimeException('读取苹果公钥失败');
    }

    $signature = joseEs256SignatureToDer(
        base64UrlDecode($encodedSignature)
    );

    $verified = openssl_verify(
        $encodedHeader . '.' . $encodedPayload,
        $signature,
        $publicKey,
        OPENSSL_ALGO_SHA256
    );

    if ($verified !== 1) {
        throw new RuntimeException('JWS 签名验证失败');
    }

    /*
     * 5. 到这里才能把 Payload 当作可信数据解析
     */
    $payload = json_decode(
        base64UrlDecode($encodedPayload),
        true,
        512,
        JSON_THROW_ON_ERROR
    );

    /*
     * 6. 检查应用与环境
     */
    $context = extractNotificationContext($payload);

    validateApplicationContext(
        $context,
        $expectedEnvironment,
        $environmentConfig
    );

    return $payload;
}

Production 接口可以这样调用:

$payload = verifyAppleV2Notification(
    $request['signedPayload'],
    'Production',
    [
        'bundleId' => 'com.example.app',
        'appAppleId' => 123456789,
    ],
    [
        '/var/www/app/config/apple-certs/AppleRootCA-G2.pem',
        '/var/www/app/config/apple-certs/AppleRootCA-G3.pem',
    ]
);

Sandbox 则明确改成:

$payload = verifyAppleV2Notification(
    $request['signedPayload'],
    'Sandbox',
    [
        'bundleId' => 'com.example.app',
        'appAppleId' => null,
    ],
    [
        '/var/www/app/config/apple-certs/AppleRootCA-G2.pem',
        '/var/www/app/config/apple-certs/AppleRootCA-G3.pem',
    ]
);

可以看到,两套环境的密码学验证代码本身并不需要复制。差别主要位于预期环境和应用身份配置。

十二、signedTransactionInfo不能再用一次简单Base64解码就结束

外层 signedPayload 通过验证以后,常见通知的 data 中还会出现:

data.signedTransactionInfo
data.signedRenewalInfo

它们本身也是 JWS。苹果文档说明 signedPayload、signedTransactionInfo 和 signedRenewalInfo 都带有可以验证的 JWS 签名。

因此,如果业务需要根据交易详情决定是否增加会员时间、确认商品ID或处理退款,推荐继续使用同样的 JWS 验证逻辑验证内部数据,而不是:

// 不建议直接这样拿交易数据做业务
$parts = explode('.', $signedTransactionInfo);
$transaction = json_decode(
    base64UrlDecode($parts[1]),
    true
);

更合理的结构是把前面的密码学部分抽象成通用函数:

verifyJws($signedPayload);
verifyJws($signedTransactionInfo);
verifyJws($signedRenewalInfo);

然后针对不同对象分别做业务字段验证。

十三、不要只验证公钥签名而跳过证书链

下面这种代码表面上也执行了 openssl_verify():

$leaf = x5cToPem($header['x5c'][0]);
$publicKey = openssl_pkey_get_public($leaf);

openssl_verify(
    $data,
    $signature,
    $publicKey,
    OPENSSL_ALGO_SHA256
);

但如果没有验证这张叶子证书是否最终连接到服务器自己的 Apple Root CA,它只能证明:

“这段数据由与这张证书配套的私钥签过。”

它不能证明:

“这张证书和私钥确实属于苹果。”

因此完整验证需要同时满足两部分:

  1. 证书链可信;
  2. JWS签名正确。

缺一项都不能视为完整验证。

十四、TestFlight要按Sandbox处理

另一个多环境环境中容易出现的判断错误,是认为“从 App Store Connect/TestFlight 安装的版本应该属于 Production”。对于 App Store Server Notifications,Apple 文档明确说明 TestFlight 使用 Sandbox 环境发送通知。

因此测试时如果看到:

"environment": "Sandbox"

并不一定说明通知配置错误。如果购买行为来自 TestFlight,这正是需要预期的结果。

十五、不要在签名验证之前根据environment选择信任策略

假设一个接口同时接收两个环境,有人可能会这样写:

$payload = decodeWithoutVerify($signedPayload);

if ($payload['data']['environment'] === 'Production') {
    $config = $productionConfig;
} else {
    $config = $sandboxConfig;
}

verify($signedPayload, $config);

问题在于,第一步读取的 environment 还没有经过任何真实性验证。

比较安全的设计是:

  1. 使用统一、固定的 Apple Root CA 信任策略完成证书链检查。
  2. 验证 JWS 签名。
  3. 签名成功后取得可信 environment。
  4. 根据环境加载对应业务配置。
  5. 继续验证 bundleId、appAppleId 等字段。

如果使用 Sandbox 和 Production 独立 URL,则更加简单:接口本身已经知道预期环境,不需要从未验证的数据推断。

十六、证书过期和历史通知需要单独考虑

证书验证还有一个不太容易注意到的问题:你是在“现在”验证通知,还是在重新处理“很久以前”的历史通知。

Apple 官方 Server Library 提供在线检查选项。Apple 在相关说明中提到,对于刚刚收到的数据可以启用证书撤销等在线检查;如果处理几个月或几年前存储的历史数据,则需要考虑证书当前可能已经过期,而数据在签名时可能是有效的。

手写 PHP OpenSSL 验证代码时,这部分尤其要谨慎。简单调用 openssl_x509_checkpurpose() 更接近对证书“当前状态”的验证,并不能自动完整复现 Apple 官方库针对签名时间、证书状态和撤销检查的全部处理逻辑。

因此,如果项目涉及:

  • 大量历史通知重新验签;
  • 长期保存JWS后再次验证;
  • 严格的证书撤销检查;
  • 支付状态直接影响高价值资产;

最好将证书验证实现进行专门安全审计,或者让一个使用 Apple 官方 App Store Server Library 的 Java、Python、Node.js 或 Swift 服务承担验签职责,再把已经验证的数据交给 PHP。Apple 官方目前提供的 App Store Server Library 语言包括 Swift、Java、Python 和 Node.js,并未列出官方 PHP 版本。

十七、业务处理还要解决重复通知

公钥和签名验证成功,只解决“通知是真的假的”,并没有解决“同一通知是否已经处理过”。

V2 Payload 提供 notificationUUID,Apple 将其定义为通知的唯一标识,可以用来识别重复通知。

数据库可以建立唯一索引,例如:

CREATE UNIQUE INDEX uk_apple_notification_uuid
ON apple_notifications(notification_uuid);

典型处理顺序是:

验签成功
    ↓
验证 environment / bundleId / appAppleId
    ↓
检查 notificationUUID 是否处理过
    ↓
验证 signedTransactionInfo
    ↓
执行业务更新
    ↓
记录 notificationUUID
    ↓
返回成功状态

这样即使苹果因为网络或服务器响应问题重复发送同一条通知,也不会重复给用户增加权益。

十八、PHP接收接口还应注意HTTP响应

当服务器成功接收并处理 App Store Server Notification 时,需要返回成功的 HTTP 状态。Apple 当前文档说明,成功接收时可以返回 200–206;如果返回 40x 或 50x,则会被视为失败并可能触发重试机制。

一个简化的入口可以写成:

<?php

try {
    $rawBody = file_get_contents('php://input');

    $request = json_decode(
        $rawBody,
        true,
        512,
        JSON_THROW_ON_ERROR
    );

    if (empty($request['signedPayload'])) {
        throw new RuntimeException('缺少 signedPayload');
    }

    $payload = verifyAppleV2Notification(
        $request['signedPayload'],
        'Production',
        [
            'bundleId' => 'com.example.app',
            'appAppleId' => 123456789,
        ],
        [
            '/var/www/app/config/apple-certs/AppleRootCA-G2.pem',
            '/var/www/app/config/apple-certs/AppleRootCA-G3.pem',
        ]
    );

    // 此处继续:
    // 1. notificationUUID 幂等检查
    // 2. signedTransactionInfo 验签
    // 3. 根据 notificationType 更新业务状态

    http_response_code(200);

} catch (Throwable $e) {
    error_log(
        'Apple V2 notification error: ' . $e->getMessage()
    );

    http_response_code(500);
}

实际项目中应根据错误类型区分“请求确定无效”和“服务器暂时失败”,而不是所有异常一律返回同一个状态码。否则一个永远无法通过验证的恶意请求也可能不断进入重试流程。

SVG 配图 4
图4:PHP处理苹果V2通知时,可以把检查项分为密码学、签名、环境身份和业务幂等四层。

十九、几个最容易踩到的坑

1. 把x5c最后一张证书直接加入可信根

这是最危险的错误之一。可信根应该预先部署在服务器上,而不是由请求告诉服务器“请信任我提供的根证书”。

2. 只Base64URL解码,不验签

JWS Payload 本来就是可以被任何人读取的。能够得到JSON并不能证明JSON来自苹果。

3. 证书链不验证,只用叶子公钥openssl_verify

这样只能确认“签名和叶子公钥对应”,无法证明叶子证书属于Apple。

4. Sandbox强制要求appAppleId

苹果文档说明 Sandbox 数据中可能不存在该字段。Production 才应将它作为应用身份的重要校验条件。

5. TestFlight当成Production

TestFlight 的服务器通知仍然属于 Sandbox。

6. 根据未验签environment决定是否信任

environment 自己也属于被签名数据的一部分。应该先建立密码学可信性,再让它参与环境路由。

7. 外层signedPayload验证完,就直接解码内层transaction

signedTransactionInfo 和 signedRenewalInfo 本身也是JWS。涉及支付权益的字段最好继续执行完整验证。

8. 把开发者p8私钥和苹果通知公钥混在一起

你自己的 App Store Connect 私钥负责“你向苹果证明你是谁”;通知中的 x5c 和苹果证书链负责“苹果向你证明通知是苹果签的”。两者方向完全相反。

二十、应该选择单接口还是Sandbox、Production双接口

两种方案都可以实现,但如果项目允许分别配置通知URL,独立接口更容易维护:

/api/apple/notifications/sandbox
/api/apple/notifications/production

好处不是因为两边要用完全不同的公钥,而是每个入口在收到请求之前就已经知道自己的预期环境。

Sandbox入口可以明确要求:

environment === 'Sandbox'
bundleId === 'com.example.app'

Production入口明确要求:

environment === 'Production'
bundleId === 'com.example.app'
appAppleId === 123456789

如果项目必须共用一个URL,也不需要害怕,只需要遵守一个原则:

先通过统一的Apple证书链和JWS签名验证,再读取environment进行后续分流。

总结

PHP处理苹果支付V2多环境公钥验证,关键并不是为 Sandbox 和 Production 各保存一把固定公钥。V2 使用 JWS 和 x5c X.509 证书链,服务器应该从叶子证书获取当前签名公钥,同时使用自己预先部署的 Apple Root CA 验证证书链。

完整顺序可以记成:

接收 signedPayload
        ↓
拆分 Header / Payload / Signature
        ↓
检查 alg = ES256
        ↓
解析 x5c
        ↓
使用服务器本地 Apple Root CA 验证证书链
        ↓
取得叶子证书公钥
        ↓
将 ES256 JOSE 签名转换为 OpenSSL DER 格式
        ↓
验证 Header.Payload 的 JWS 签名
        ↓
签名成功后读取 environment
        ↓
验证 bundleId
        ↓
Production 再验证 appAppleId
        ↓
验证 signedTransactionInfo / signedRenewalInfo
        ↓
使用 notificationUUID 做幂等
        ↓
更新订单、订阅或用户权益

只做到“能Base64解码”远远不够;只做到“openssl_verify返回1”也还不完整。对于支付通知,证书链、JWS签名、环境、应用身份和业务幂等应该作为一套连续验证流程处理。这样Sandbox、TestFlight和Production同时存在时,PHP服务才能既正确识别环境,又避免把来自错误应用、错误环境或伪造证书的数据送进真实支付业务。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。