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') 不同。
正确的处理顺序
- 使用
$this->request->getBody()读取原始请求体。 - 读取
X-Delyvax-Hmac-Sha256请求头。 - 从安全配置中取得 webhook secret。
- 对原始请求体执行 HMAC-SHA256。
- 将二进制摘要转换为 Base64。
- 使用
hash_equals()比较签名。 - 签名通过后再解析和处理 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,只要它来自真实环境,就应撤销或轮换,也不应继续保存在源码或日志中。
备注:内容仅供参考。
