使用 PHP 实现 Volume webhook 支付签名验证
结论
openssl_verify() 返回 0,通常是以下两个原因:
- Volume 接口返回的内容本身已经是完整的 PEM 公钥,代码却又添加了一层
-----BEGIN PUBLIC KEY-----和-----END PUBLIC KEY-----,破坏了 PEM 格式。 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,可以按以下顺序检查:
- 公钥和 webhook 是否来自同一环境。例如,sandbox webhook 必须使用 sandbox 公钥。
- signature 是否确实属于同一次 webhook 请求。
- 是否直接读取了
php://input,以及代理、中间件或框架是否提前消费或改写了请求体。 - 请求头中的签名是否带有
sha256=等前缀。如果有,必须按照 Volume 的格式说明去除,不能直接对整个值进行 Base64 解码。 - 公钥接口是否发生过密钥轮换。缓存公钥时需要设计刷新机制。
- Volume 规定的算法是否确实是 RSA + SHA-256。如果文档指定 RSA-PSS、签名预计算摘要或其他 canonicalization 规则,就必须按相应规则实现,不能直接使用上面的 PKCS#1 v1.5 示例。
- 打印
strlen($payload)和hash('sha256', $payload),与发送端的记录进行比对。不要在生产日志中输出完整的支付数据或签名。 - 检查公钥接口的 HTTP 状态、
Content-Type和响应开头,避免将 HTML 错误页当作 PEM 解析。
本例最直接的修复有两处:删除重复的 PEM 包装,并将 $payload 而不是 $hashedPayload 传给 openssl_verify()。
备注:内容仅供参考。
