PAYATHON 2026

使用 PHP 实现 Volume webhook 支付签名验证

支付阿杰

结论

openssl_verify() 返回 0,通常是以下两个原因:

  1. Volume 接口返回的内容本身已经是完整的 PEM 公钥,代码却又添加了一层 -----BEGIN PUBLIC KEY----- 和 -----END PUBLIC KEY-----,破坏了 PEM 格式。
  2. openssl_verify(..., OPENSSL_ALGO_SHA256) 会在内部对传入的数据执行 SHA-256。如果代码先对 payload 计算一次 SHA-256,再把结果交给 openssl_verify(),验证的其实是 SHA256(SHA256(payload)),无法匹配 Volume 针对原始 webhook body 生成的签名。

处理方式很明确:直接加载完整 PEM,严格解码 Base64 签名,并把收到的原始 HTTP 请求体原样传给 openssl_verify()。

错误分别代表什么

PEM routines::no start line

OpenSSL 没有在预期位置找到合法的 PEM 起始行。常见原因有:

  • 给完整 PEM 再次添加了 PEM 头尾;
  • 接口返回的是 JSON、HTML 错误页或其他非 PEM 内容;
  • PEM 含有 BOM、转义后的 \n,或者正文被截断;
  • 沙箱环境和生产环境使用了错误的公钥地址。

如果接口返回:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqh...
-----END PUBLIC KEY-----

应直接将其传给 openssl_pkey_get_public(),不要再次包装。

rsa routines::bad signature

这表示 RSA 运算已经完成,但从签名中得到的摘要与当前计算出的摘要不同。通常是以下内容没有匹配:

  • 验证时使用的不是原始 webhook body;
  • payload 被重新编码、格式化或修改;
  • 数据提前经过哈希,导致重复哈希;
  • 使用了错误环境的公钥,或公钥已经轮换;
  • signature 并不属于这次 webhook;
  • 签名算法或填充方式与发送方的规定不一致。

Provider routines::RSA lib

这是 OpenSSL 3 provider 层针对底层 RSA 验证失败给出的补充错误,往往会和 bad signature 一起出现。它不表示 PHP 还需要安装额外的 RSA 库。

OpenSSL 的错误队列会保留同一进程中先前产生的错误。即使 openssl_pkey_get_public() 最后成功了,之前解析公钥时留下的 PEM 错误仍可能在验证失败后被打印出来。因此,排查不同操作时,应分别读取或清空错误队列。

正确的验证代码

下面的代码假设公钥地址返回完整的 PEM 文本,并且 Volume 使用常见的 RSA PKCS#1 v1.5 + SHA-256 签名方式。签名请求头的具体名称,应以 Volume 当前的 webhook 文档或实际请求为准。

<?php

declare(strict_types=1);

$publicKeyUrl = 'https://api.sandbox.volumepay.io/.well-known/signature/pem';

/*
 * 必须在 json_decode() 或任何重新编码之前读取原始请求体。
 */
$payload = file_get_contents('php://input');

if ($payload === false) {
    http_response_code(400);
    exit('Unable to read request body');
}

/*
 * 将此处替换成 Volume 实际使用的签名请求头。
 * $_SERVER 中的名称取决于 Web 服务器和请求头名称。
 */
$signatureBase64 = $_SERVER['HTTP_VOLUME_SIGNATURE'] ?? '';

if ($signatureBase64 === '') {
    http_response_code(400);
    exit('Missing signature');
}

/*
 * 获取 Volume 公钥。
 * 生产环境应使用生产环境对应的公钥地址,并考虑超时、缓存和密钥轮换。
 */
$pem = file_get_contents($publicKeyUrl);

if ($pem === false) {
    http_response_code(500);
    exit('Unable to retrieve public key');
}

$pem = trim($pem);

/*
 * 接口应返回完整 PEM,所以不要无条件拼接 BEGIN/END 行。
 */
if (
    !str_contains($pem, '-----BEGIN PUBLIC KEY-----') &&
    !str_contains($pem, '-----BEGIN RSA PUBLIC KEY-----')
) {
    http_response_code(500);
    exit('Public key endpoint did not return a valid PEM document');
}

/*
 * 清除早先遗留的 OpenSSL 错误,便于准确定位本次操作。
 */
while (openssl_error_string() !== false) {
}

$publicKey = openssl_pkey_get_public($pem);

if ($publicKey === false) {
    $errors = [];

    while (($error = openssl_error_string()) !== false) {
        $errors[] = $error;
    }

    error_log('Unable to parse Volume public key: ' . implode('; ', $errors));
    http_response_code(500);
    exit('Unable to load public key');
}

/*
 * 标准 Base64 中的 "/" 是合法字符,不应把 "//" 替换成 "/"。
 * 如果字符串来自 JSON,json_decode() 已经会处理 "\/"。
 */
$signatureBase64 = trim($signatureBase64);
$signature = base64_decode($signatureBase64, true);

if ($signature === false) {
    http_response_code(400);
    exit('Invalid Base64 signature');
}

while (openssl_error_string() !== false) {
}

/*
 * 传入原始 payload。
 * OPENSSL_ALGO_SHA256 会在内部计算 SHA-256,不能先调用 hash()。
 */
$result = openssl_verify(
    $payload,
    $signature,
    $publicKey,
    OPENSSL_ALGO_SHA256
);

if ($result === 1) {
    http_response_code(200);

    // 只有验证成功后,才解析和处理 webhook。
    $event = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);

    echo 'Signature verified';
    exit;
}

if ($result === 0) {
    /*
     * 0 表示签名有效性检查已执行,但签名不匹配。
     * 不应把 OpenSSL 的内部错误详情返回给请求方。
     */
    while (($error = openssl_error_string()) !== false) {
        error_log('OpenSSL verification detail: ' . $error);
    }

    http_response_code(401);
    exit('Invalid signature');
}

/*
 * -1 或 false 表示 OpenSSL 本身发生错误。
 */
$errors = [];

while (($error = openssl_error_string()) !== false) {
    $errors[] = $error;
}

error_log('OpenSSL verification error: ' . implode('; ', $errors));
http_response_code(500);
exit('Signature verification error');

修正原测试代码的关键部分

原代码不应这样构造公钥:

$publicKey = "-----BEGIN PUBLIC KEY-----\n"
    . trim($pemContent)
    . "\n-----END PUBLIC KEY-----";

如果响应本身已经是 PEM,应改成:

$publicKey = trim($pemContent);
$publicKeyResource = openssl_pkey_get_public($publicKey);

验证时也不要传入 $hashedPayload:

$hashedPayload = hash('sha256', $payload, true);

$result = openssl_verify(
    $hashedPayload,
    $signature,
    $publicKeyResource,
    OPENSSL_ALGO_SHA256
);

应直接验证原始 payload:

$result = openssl_verify(
    $payload,
    $signature,
    $publicKeyResource,
    OPENSSL_ALGO_SHA256
);

如果需要记录日志或排查问题,可以另行计算哈希,但不能用它代替传给 openssl_verify() 的原文:

$payloadHash = hash('sha256', $payload);
error_log('Payload SHA-256: ' . $payloadHash);

Base64 signature 的处理

/、+ 和 = 都是标准 Base64 的合法字符,通常不需要做下面这种替换:

$signatureBase64 = str_replace('\/', '/', $signatureBase64);

如果签名来自 JSON:

$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
$signatureBase64 = $data['signature'];

json_decode() 会自动将 JSON 中的 \/ 还原为 /。

绝对不要执行:

$signatureBase64 = str_replace('//', '/', $signatureBase64);

// 可能原本就是签名 Base64 内容的一部分。删除其中一个 / 会改变签名字节。

解码时建议始终开启严格模式:

$signature = base64_decode(trim($signatureBase64), true);

if ($signature === false) {
    throw new RuntimeException('Invalid Base64 signature');
}

只有当 Volume 实际发送的是 Base64URL,而不是标准 Base64 时,才需要把 -、_ 转换为 +、/,并补齐 =。不能因为签名中出现 /,就认定它是 Base64URL。

必须验证原始 webhook body

签名对应的是一段确定的字节序列,而不是 JSON 对象的逻辑内容。下面两个 JSON 表达的是同一组数据,但会得到不同的签名结果:

{"amount":9.99,"currency":"GBP"}
{
  "currency": "GBP",
  "amount": 9.99
}

因此,不能先执行以下操作再验证:

$data = json_decode($payload, true);
$payload = json_encode($data);

重新编码可能改变以下内容:

  • 属性顺序;
  • 空格和换行;
  • Unicode 转义形式;
  • / 的转义形式;
  • 数字表示;
  • 行尾字符。

正确顺序是:

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

// 先验证 $rawBody

// 验证成功后再解析
$data = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);

测试代码中手写的 JSON 字符串,也必须与 Volume 生成签名时使用的原始 body 完全一致。即使 JSON 的内容看起来相同,同时也拿到了某次 webhook 的 signature 和当前公钥,仍不能保证验证成功。

进一步排查

如果修正公钥加载和重复哈希后仍然返回 0,可以按以下顺序检查:

  1. 公钥和 webhook 是否来自同一环境。例如,sandbox webhook 必须使用 sandbox 公钥。
  2. signature 是否确实属于同一次 webhook 请求。
  3. 是否直接读取了 php://input,以及代理、中间件或框架是否提前消费或改写了请求体。
  4. 请求头中的签名是否带有 sha256= 等前缀。如果有,必须按照 Volume 的格式说明去除,不能直接对整个值进行 Base64 解码。
  5. 公钥接口是否发生过密钥轮换。缓存公钥时需要设计刷新机制。
  6. Volume 规定的算法是否确实是 RSA + SHA-256。如果文档指定 RSA-PSS、签名预计算摘要或其他 canonicalization 规则,就必须按相应规则实现,不能直接使用上面的 PKCS#1 v1.5 示例。
  7. 打印 strlen($payload) 和 hash('sha256', $payload),与发送端的记录进行比对。不要在生产日志中输出完整的支付数据或签名。
  8. 检查公钥接口的 HTTP 状态、Content-Type 和响应开头,避免将 HTML 错误页当作 PEM 解析。

本例最直接的修复有两处:删除重复的 PEM 包装,并将 $payload 而不是 $hashedPayload 传给 openssl_verify()。

备注:内容仅供参考。