如何在 PHP 和 CodeIgniter 4 中用 .crt 验证 Bluem webhook 签名
结论
.crt 文件通常保存 Bluem 的公钥证书。验证 webhook 时,并不是把 Signature 字符串与 .crt 文件直接比较,而是按以下步骤处理:
- 获取 webhook 的原始请求体,或 Bluem 规定的待签名字符串。
- 按 Bluem 文档指定的编码方式解析 Signature,例如 Base64。
- 从
.crt证书中提取公钥。 - 使用 Bluem 指定的签名算法调用
openssl_verify()。 - 只有返回值为
1时,签名才有效。
仅凭 .crt 文件和 Signature 无法确定完整的验证方式。还需要从 Bluem 的接口文档中确认:
- Signature 位于哪个 HTTP Header 或字段;
- 实际签名的内容是什么;
- Signature 使用 Base64、Hex 还是其他编码;
- 签名算法是 RSA-SHA256、RSA-PSS、ECDSA,还是 JWS/CMS;
- 待签名内容是否包含时间戳、请求路径、HTTP 方法等信息。
下面的代码基于一种常见方案:Bluem 使用 SHA-256 with RSA 对原始请求体签名,再将签名编码为 Base64。如果 Bluem 文档采用其他规则,应以官方说明为准。
PHP 验证示例
<?php
function verifyBluemSignature(
string $rawBody,
string $signatureString,
string $certificatePath
): bool {
$certificateContents = file_get_contents($certificatePath);
if ($certificateContents === false) {
throw new RuntimeException('Unable to read Bluem certificate.');
}
$certificate = openssl_x509_read($certificateContents);
if ($certificate === false) {
throw new RuntimeException('Invalid Bluem X.509 certificate.');
}
$publicKey = openssl_pkey_get_public($certificate);
if ($publicKey === false) {
throw new RuntimeException('Unable to extract public key from certificate.');
}
// 此处假设 webhook 中的 Signature 使用 Base64 编码。
$signature = base64_decode(trim($signatureString), true);
if ($signature === false) {
throw new InvalidArgumentException('Signature is not valid Base64.');
}
$result = openssl_verify(
$rawBody,
$signature,
$publicKey,
OPENSSL_ALGO_SHA256
);
if ($result === 1) {
return true;
}
if ($result === 0) {
return false;
}
// -1 表示 OpenSSL 执行验证时发生错误,而不是普通的签名不匹配。
$errors = [];
while (($error = openssl_error_string()) !== false) {
$errors[] = $error;
}
throw new RuntimeException(
'OpenSSL verification error: ' . implode('; ', $errors)
);
}
调用方式:
$rawBody = file_get_contents('php://input');
$signatureString = $_SERVER['HTTP_SIGNATURE'] ?? '';
$isValid = verifyBluemSignature(
$rawBody,
$signatureString,
'/secure/path/bluem.crt'
);
if (!$isValid) {
http_response_code(401);
exit('Invalid signature');
}
http_response_code(200);
echo 'OK';
这里的 HTTP_SIGNATURE 只是示例。实际变量名由 Bluem 使用的 Header 决定,可能是 Signature、X-Signature 或其他名称。
CodeIgniter 4 中的实现
可以把验证逻辑放在独立的服务类中:
<?php
namespace App\Services;
use InvalidArgumentException;
use RuntimeException;
class BluemWebhookVerifier
{
public function __construct(
private readonly string $certificatePath
) {
}
public function verify(string $payload, string $signatureString): bool
{
$certificateContents = file_get_contents($this->certificatePath);
if ($certificateContents === false) {
throw new RuntimeException('Unable to read Bluem certificate.');
}
$certificate = openssl_x509_read($certificateContents);
if ($certificate === false) {
throw new RuntimeException('Invalid Bluem X.509 certificate.');
}
$publicKey = openssl_pkey_get_public($certificate);
if ($publicKey === false) {
throw new RuntimeException(
'Unable to extract public key from Bluem certificate.'
);
}
// 如果 Bluem 使用 Hex 编码,这里应改用 hex2bin()。
$signature = base64_decode(trim($signatureString), true);
if ($signature === false) {
throw new InvalidArgumentException('Invalid signature encoding.');
}
$result = openssl_verify(
$payload,
$signature,
$publicKey,
OPENSSL_ALGO_SHA256
);
if ($result === 1) {
return true;
}
if ($result === 0) {
return false;
}
throw new RuntimeException('OpenSSL failed to verify the signature.');
}
}
控制器必须读取原始请求体:
<?php
namespace App\Controllers;
use App\Services\BluemWebhookVerifier;
use CodeIgniter\HTTP\ResponseInterface;
use Throwable;
class BluemWebhookController extends BaseController
{
public function receive(): ResponseInterface
{
$rawBody = $this->request->getBody();
// Header 名称必须替换成 Bluem 文档规定的名称。
$signatureString = $this->request->getHeaderLine('Signature');
if ($signatureString === '') {
return $this->response
->setStatusCode(400)
->setJSON(['error' => 'Missing signature']);
}
$verifier = new BluemWebhookVerifier(
WRITEPATH . 'certs/bluem.crt'
);
try {
if (!$verifier->verify($rawBody, $signatureString)) {
return $this->response
->setStatusCode(401)
->setJSON(['error' => 'Invalid signature']);
}
} catch (Throwable $exception) {
log_message(
'error',
'Bluem signature verification failed: {message}',
['message' => $exception->getMessage()]
);
return $this->response
->setStatusCode(400)
->setJSON(['error' => 'Unable to verify signature']);
}
// 只有签名验证成功后,才能解析和处理业务数据。
$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
// 业务处理应实现幂等性,避免重复 webhook 导致重复入账。
// processWebhook($payload);
return $this->response
->setStatusCode(200)
->setJSON(['received' => true]);
}
}
不要把证书放在 public 目录中。上述示例将证书保存在:
writable/certs/bluem.crt
同时还应限制该文件的系统访问权限。
为什么必须使用原始请求体
签名对应的是一组确定的字节。以下操作都可能改变这些字节:
- 解析 JSON 后重新调用
json_encode(); - 调整字段顺序;
- 改变空格或换行;
- 改变 Unicode 转义方式;
- 修改数字、布尔值或空值的表示;
- 自动转换字符编码。
例如,下面两段 JSON 的业务含义相同,但参与签名时属于不同的数据:
{"status":"paid","amount":100}
{
"amount": 100,
"status": "paid"
}
因此,应先通过 $this->request->getBody() 获取原始内容并验证签名,验证成功后再解析 JSON。
如果 Bluem 文档规定签名内容是规范化字符串,例如:
timestamp + "." + rawBody
验证时也必须使用完全相同的方式构造字符串:
$signedContent = $timestamp . '.' . $rawBody;
调用 openssl_verify() 时,应传入 $signedContent,而不是单独传入 $rawBody。
Signature 编码必须匹配
如果 Bluem 返回 Base64:
$signature = base64_decode($signatureString, true);
如果返回十六进制字符串:
$signature = hex2bin($signatureString);
if ($signature === false) {
throw new InvalidArgumentException('Invalid hexadecimal signature.');
}
不能把可见的 Base64 或 Hex 文本直接传给 openssl_verify()。该函数需要解码后的二进制签名。
如果 Signature 类似下面的格式:
eyJhbGciOiJSUzI1NiIs...
并且包含三个由句点分隔的部分,它可能是 JWS/JWT,而不是普通的 Base64 签名。这种情况需要按照 JWS 规则验证,不能直接使用前面的代码。
检查证书本身
还可以读取证书信息并检查有效期:
$certificateContents = file_get_contents(
WRITEPATH . 'certs/bluem.crt'
);
$certificate = openssl_x509_read($certificateContents);
$certificateInfo = openssl_x509_parse($certificate);
if ($certificateInfo === false) {
throw new RuntimeException('Unable to parse certificate.');
}
$currentTime = time();
if (
$currentTime < $certificateInfo['validFrom_time_t']
|| $currentTime > $certificateInfo['validTo_time_t']
) {
throw new RuntimeException('Bluem certificate is outside its validity period.');
}
也可以固定证书指纹,防止服务器上的证书被意外替换:
$expectedFingerprint = 'BLUEM_PROVIDED_SHA256_FINGERPRINT';
$actualFingerprint = openssl_x509_fingerprint(
$certificate,
'sha256'
);
if (
$actualFingerprint === false
|| !hash_equals(
strtolower(str_replace(':', '', $expectedFingerprint)),
strtolower(str_replace(':', '', $actualFingerprint))
)
) {
throw new RuntimeException('Unexpected Bluem certificate.');
}
预期指纹必须通过 Bluem 的可信渠道获取,不能从未经验证的 webhook 中读取。
openssl_verify() 验证成功,只能证明该签名可以通过证书中的公钥验证,并不能自动证明证书确实属于 Bluem。证书必须来自可信来源。如果 Bluem 提供了证书链及相应的信任校验要求,还需要按照其文档验证证书链。
常见失败原因
如果签名始终验证失败,可以优先检查以下问题:
- 使用了重新编码后的 JSON,而不是原始请求体;
- Signature 未先进行 Base64 或 Hex 解码;
- 读取了错误的 Header;
- 使用了错误的哈希算法;
- Bluem 实际签名的是拼接后的字符串,而不是单独的请求体;
- 待签名内容遗漏了时间戳、路径、查询参数或 HTTP 方法;
- 把 Base64URL 当成了普通 Base64;
- 证书实际采用 DER 格式,代码却按 PEM 格式读取;
- Bluem 使用 RSA-PSS、ECDSA、JWS 或 CMS,代码却按 RSA PKCS#1 v1.5 验证;
- 测试环境与生产环境使用了不同的证书。
最有效的排查材料是一组 Bluem 官方测试向量,其中应包含完整的原始请求体、Signature、证书和算法说明。只有具备这些信息,才能确认编码方式、待签名内容和算法是否完全一致。
安全上的补充处理
签名验证通过后,还应检查:
- webhook 时间戳是否处于允许的时间窗口内;
- event ID 或 transaction ID 是否已经处理过;
- 商户号、金额、币种和订单号是否与本地记录一致;
- 证书是否需要轮换;
- 日志中是否避免记录完整支付数据、Signature 或敏感字段;
- 普通摘要应使用
hash_equals()比较,但不能用它代替公钥签名验证。
处理时应使用可信 .crt 证书中的公钥,对 Bluem 明确定义的原始待签名内容进行密码学验证。只有 openssl_verify() 返回 1 时才能接受该 webhook。返回 0 或 -1 时,都不应继续处理支付业务。
备注:内容仅供参考。
