PAYATHON 2026

如何在 PHP 和 CodeIgniter 4 中用 .crt 验证 Bluem webhook 签名

支付小周

结论

.crt 文件通常保存 Bluem 的公钥证书。验证 webhook 时,并不是把 Signature 字符串与 .crt 文件直接比较,而是按以下步骤处理:

  1. 获取 webhook 的原始请求体,或 Bluem 规定的待签名字符串。
  2. 按 Bluem 文档指定的编码方式解析 Signature,例如 Base64。
  3. 从 .crt 证书中提取公钥。
  4. 使用 Bluem 指定的签名算法调用 openssl_verify()。
  5. 只有返回值为 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 时,都不应继续处理支付业务。

备注:内容仅供参考。