PAYATHON 2026

PHP CodeIgniter4 如何匹配 Node JSON.stringify 验证 SHA256

排障赵师傅

结论

验证签名时,应使用 CodeIgniter4 接收到的原始请求体。不要先调用 getJSON(),再用 json_encode() 重建 JSON。

密钥必须使用 Delyva 提供的 webhook secret。它和 API key 是两种不同的凭据。密钥错误或参与签名的字节发生变化,都会导致 HMAC 结果不匹配。

PHP 的计算方式如下:

$expected = base64_encode(
    hash_hmac('sha256', $rawBody, $apiSecret, true)
);

这里的 $rawBody 必须是未经修改的原始 HTTP request body。

重新编码 JSON 为什么会导致签名不一致

HMAC 校验比较的是字节序列,而不是 JSON 解析后的内容。下面两段 JSON 表示相同的数据,但字节并不相同:

{"amount":0,"currency":"MYR"}
{
  "amount": 0,
  "currency": "MYR"
}

空格、换行、属性顺序、斜杠转义、Unicode 转义和数字格式都会改变最终签名。

因此,下面的代码可能导致校验失败:

$parsedBody = $this->request->getJSON();
$payload = json_encode(
    $parsedBody,
    JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);

getJSON() 会先把请求体转换成 PHP 对象,随后执行 json_encode() 得到的是一份新的 JSON 字符串。这份字符串无法保证与收到的内容逐字节一致,也无法在所有数据上完整复现 Node.js 的 JSON.stringify() 行为。

Delyva 的这个集成应直接验证原始请求体,也就是验证提供方实际签名的内容。

CodeIgniter4 完整示例

public function postTracking(): ResponseInterface
{
    // 必须是 Delyva 提供的 webhook secret,不是 API key
    $apiSecret = getenv('DELYVA_WEBHOOK_SECRET');

    if (!is_string($apiSecret) || $apiSecret === '') {
        log_message('critical', 'Delyva webhook secret is not configured');

        return $this->failServerError('Webhook configuration error');
    }

    // 先保存原始请求体,签名计算期间不得修改
    $rawBody = $this->request->getBody();

    // CodeIgniter4 的请求头名称匹配不区分大小写
    $signature = $this->request->getHeaderLine(
        'X-Delyvax-Hmac-Sha256'
    );

    // 缺少签名时建议直接拒绝请求
    if ($signature === '') {
        log_message('warning', 'Delyva webhook signature is missing');

        return $this->failUnauthorized('Missing signature');
    }

    // 第四个参数 true 表示返回二进制摘要
    $digest = hash_hmac('sha256', $rawBody, $apiSecret, true);
    $expectedSignature = base64_encode($digest);

    // 使用恒定时间比较,避免直接使用 === 比较敏感签名
    if (!hash_equals($expectedSignature, $signature)) {
        log_message('error', 'Invalid Delyva webhook signature');

        return $this->failUnauthorized('Invalid signature');
    }

    // 签名通过后再解析并处理 JSON
    try {
        $jsonData = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
    } catch (\JsonException $e) {
        log_message('error', 'Invalid Delyva webhook JSON: ' . $e->getMessage());

        return $this->failValidationErrors('Invalid JSON payload');
    }

    // 在这里处理已经验证过的数据
    // $courierOrderId = $jsonData['orderId'] ?? null;

    return $this->respond([
        'status' => 'success',
    ]);
}

Node.js 与 PHP 的对应关系如下:

Node.js createHmac('sha256', secret)
PHP    hash_hmac('sha256', payload, secret, true)

Node.js update(payload)
PHP    $rawBody

Node.js digest('base64')
PHP    base64_encode($binaryDigest)

hash_hmac() 的第四个参数必须设为 true,这样返回的才是二进制摘要。如果省略该参数或设为 false,函数会返回十六进制文本。对这段文本进行 Base64 编码,结果与 Node.js 的 digest('base64') 不同。

正确的处理顺序

  1. 使用 $this->request->getBody() 读取原始请求体。
  2. 读取 X-Delyvax-Hmac-Sha256 请求头。
  3. 从安全配置中取得 webhook secret。
  4. 对原始请求体执行 HMAC-SHA256。
  5. 将二进制摘要转换为 Base64。
  6. 使用 hash_equals() 比较签名。
  7. 签名通过后再解析和处理 JSON。

Node.js 示例中的序列化问题

示例中的 Express 流程是:

app.use(express.json());
const payload = JSON.stringify(req.body);

这段代码会先解析请求体,再将其序列化为紧凑 JSON。只有服务端明确规定签名对象是重新序列化后的数据时,接收方才需要复现这套序列化规则。

实际 webhook 协议通常直接签名发出的原始请求体,以免不同语言的 JSON 序列化方式影响校验。根据问题中已经确认可用的处理方式,Delyva 集成应以收到的原始 body 为准,无需在 PHP 中模拟 JSON.stringify()。

如果 API 文档以后明确要求签名规范化后的 JSON,还需要确认属性排序、数字格式、Unicode 转义、斜杠转义和空值处理规则。仅添加 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,无法保证 PHP 与 JavaScript 在所有情况下生成完全相同的字符串。

签名不匹配时的排查项

可以依次检查:

  • 使用的是 webhook secret,而不是 API key。
  • secret 中没有多余的空格、引号或换行。
  • 参与计算的是原始 body,未经过 json_decode()、json_encode()、格式化或字符集转换。
  • hash_hmac() 的第四个参数为 true。
  • Base64 编码的内容是二进制 HMAC 摘要。
  • 请求到达应用前,代理、中间件或日志组件没有改写请求体。
  • 测试工具发送的请求体与 webhook 服务实际发送的字节完全一致。
  • 没有把复制后经过美化的 JSON 当作原始请求体重新测试。

调试时可以记录请求体长度和摘要,但不要把 secret、完整签名或含有个人信息的 webhook 内容写入生产日志。例如:

log_message('debug', 'Webhook body length: ' . strlen($rawBody));
log_message('debug', 'Webhook body SHA-256: ' . hash('sha256', $rawBody));

问题中曾出现过类似凭据的字符串。即使后来确认它不是正确的 webhook secret,只要它来自真实环境,就应撤销或轮换,也不应继续保存在源码或日志中。

备注:内容仅供参考。