Click Pay 文档Click Pay 文档
  • en-US
  • 简体中文
  • en-US
  • 简体中文
  • PHP 签名与验签示例

PHP 签名与验签示例

运行环境

PHP 8.1+,开启 OpenSSL 和 cURL 扩展。

统一规则

这些示例用于默认商户 API 和默认平台回调,不适用于另行约定的定制签名格式。中英文页面引用同一份可执行代码。

有请求体的 API 请求,使用商户私钥签名:

path + lowercaseHex(SHA512(rawBody)) + Timestamp

无请求体的 API 请求,使用商户私钥签名:

path + Timestamp

默认异步回调,使用平台公钥验签:

lowercaseHex(SHA512(rawBody)) + Timestamp

API 同步响应封装,使用平台公钥验签:

lowercaseHex(SHA512(平台序列化的 data 字节)) + timestamp
  • 算法统一为 RSA PKCS#1 v1.5 + SHA-512,签名使用标准 Base64,不是 RSA-PSS,也不是对原文做 RSA 加密。
  • Timestamp 使用秒级 Unix 时间戳;验签使用收到的时间戳,不要重新生成。Header 名称不区分大小写,但 Base64 签名值区分大小写。
  • 请求 path 是接口路径,不包含协议、域名或查询字符串。回调原文不拼路径、URL 或分隔符。
  • JSON 只序列化一次:签名和发送必须使用同一份 UTF-8 字节。字段顺序不要求预先排序,但空格、转义、换行、数字表示和顺序必须与发送内容完全一致。
  • 无请求体使用 null / nil / None;它与“存在但长度为零的请求体”不同。回调始终对收到的完整 body 求摘要。
  • 私钥支持未加密 PKCS#1、PKCS#8;公钥支持 X.509 SPKI、PKCS#1;支持 PEM、纯 Base64 和转义换行。证书、加密私钥、SSH 格式及非 RSA 密钥不在本示例支持范围内。
  • 商户请求用商户私钥;验签必须使用后台配置的 平台公钥。不能使用商户自己的公钥,也不能信任请求中携带的公钥。不要发送私钥给技术支持;泄露后应更换。

失败处理

签名失败抛出异常或返回 error,调用方必须停止发送,不能用空签名继续请求。验签成功正常返回,失败记录 [payment-verify] FAILED 并抛出异常或返回 error;不得忽略失败后继续入账。

日志包含阶段、字节长度和错误原因,不记录原始请求体、完整签名或密钥。生产环境不要打开 HTTP 正文日志;不要把内部诊断错误原样返回给调用方,并对失败请求和日志做限流。

HTTPS 必须校验证书与主机名。网络超时不能证明订单创建失败,不能盲目自动重新下单;应按商户订单号查询或人工核查。

回调接入

先限制请求体大小,读取原始字节,读取 Timestamp、Signature,验签成功后再解析 JSON。检查订单归属、金额、币种和允许的状态变化,通过事务做持久化幂等处理。只有完成可靠处理后才按协议返回 SUCCESS。重复通知不得重复产生资金变动。

验签函数只负责密码学校验,不等于防重放。时间窗口应结合平台重试规则设置;重放检测、持久化幂等、回调请求限流仍由接入方实现。不要因固定短时间窗口而拒绝正常重试。

同步响应不能当作回调

API 同步响应从 JSON 的 timestamp、sign 字段取值,验签对象是 data 的平台序列化字节,不是整个响应体;回调则从 Header 取值,验签对象是完整 HTTP body。

不要把 data 解析后重新序列化再验签:大整数、1.00、转义或字段顺序可能改变。使用能保留 JSON 原始值文本的解析器提取 data(例如 Go json.RawMessage、C# JsonElement.GetRawText()),并把这些 UTF-8 字节传给示例的 verifyPlatform / VerifyPlatform / verify_platform。data 是字符串时保留 JSON 引号与转义,是 null 时保留字面量 null;不要自行添加换行或空格。缺少签名、字段异常或验签失败必须拒绝作为可信业务结果。

接口路径

同一请求函数可用于以下路径,参数以相应币种文档为准:

方法路径用途
POST/v1/merchant/pay代收下单
POST/v1/merchant/pay/order/info代收查单
POST/v1/merchant/payout代付下单
POST/v1/merchant/payout/order/info代付查单
GET/v1/merchant/account余额查询,无请求体

签名与验签完整代码

SignUtil.php

<?php
declare(strict_types=1);

final class SignUtil
{
    private static function timestamp(string $text): string
    {
        if (!preg_match('/\A[1-9][0-9]{0,18}\z/', $text)
            || (strlen($text) === 19 && strcmp($text, '9223372036854775807') > 0)) {
            throw new RuntimeException('invalid Timestamp: use Unix seconds');
        }
        return $text;
    }

    private static function base64(string $text, string $label): string
    {
        $value = base64_decode($text, true);
        if ($value === false || $value === '' || base64_encode($value) !== $text) {
            throw new RuntimeException($label . ': invalid Base64');
        }
        return $value;
    }

    // Wrap an existing PKCS#1 RSA public-key DER structure in X.509 SPKI.
    // OpenSSL performs ASN.1 parsing and RSA key validation, not this wrapper.
    private static function der(int $tag, string $value): string
    {
        $size = strlen($value);
        $length = '';
        if ($size < 128) { $length = chr($size); }
        else {
            while ($size > 0) { $length = chr($size & 255) . $length; $size >>= 8; }
            $length = chr(128 | strlen($length)) . $length;
        }
        return chr($tag) . $length . $value;
    }

    private static function key(string $input, bool $private): OpenSSLAsymmetricKey
    {
        if (strlen($input) > 16384) { throw new RuntimeException('key: exceeds 16 KiB'); }
        $text = trim(str_replace(['\\r\\n', '\\n'], ["\n", "\n"], $input));
        if (str_starts_with($text, '-----BEGIN')) {
            $labels = $private ? ['PRIVATE KEY', 'RSA PRIVATE KEY'] : ['PUBLIC KEY', 'RSA PUBLIC KEY'];
            if (!preg_match('/\A-----BEGIN ([A-Z ]+)-----\s*([A-Za-z0-9+\/=\s]+)\s*-----END \1-----\z/', $text, $match)
                || !in_array($match[1], $labels, true)) {
                throw new RuntimeException('key: expected one unencrypted RSA PEM block of the correct kind');
            }
            $text = $match[2];
        }
        $der = self::base64(preg_replace('/\s/', '', $text), 'key');
        $algorithm = hex2bin('300d06092a864886f70d0101010500');
        $candidates = $private
            ? [['PRIVATE KEY', $der], ['RSA PRIVATE KEY', $der]]
            : [['PUBLIC KEY', $der], ['PUBLIC KEY', self::der(48, $algorithm . self::der(3, "\0" . $der))]];
        foreach ($candidates as [$label, $bytes]) {
            while (openssl_error_string() !== false) { /* Clear errors from previous attempts. */ }
            $pem = '-----BEGIN ' . $label . "-----\n" . chunk_split(base64_encode($bytes), 64, "\n") . '-----END ' . $label . "-----\n";
            $key = $private ? openssl_pkey_get_private($pem) : openssl_pkey_get_public($pem);
            if ($key !== false) {
                $details = openssl_pkey_get_details($key);
                if ($details !== false && $details['type'] === OPENSSL_KEYTYPE_RSA) { return $key; }
            }
        }
        throw new RuntimeException('key: invalid/truncated, encrypted or non-RSA key');
    }

    private static function logFailure(string $operation, ?string $body, Throwable $error): void
    {
        error_log('[payment-' . $operation . '] FAILED body_bytes=' . strlen($body ?? '')
            . ' error=' . json_encode($error->getMessage()));
    }

    // PHP strings contain bytes. null means no body; '' means a present, empty body.
    public static function signRequest(string $path, ?string $body, string $timestamp, string $merchantPrivateKey): string
    {
        try {
            if (!str_starts_with($path, '/')) { throw new RuntimeException('path: expected API path'); }
            $origin = $path . ($body === null ? '' : hash('sha512', $body)) . self::timestamp($timestamp);
            $key = self::key($merchantPrivateKey, true);
            if (!openssl_sign($origin, $signature, $key, OPENSSL_ALGO_SHA512)) {
                throw new RuntimeException('OpenSSL RSA SHA512 signing failed');
            }
            return base64_encode($signature);
        } catch (Throwable $e) { self::logFailure('sign', $body, $e); throw $e; }
    }

    // Exact callback bytes, or exact signed data bytes for API envelopes. NO path.
    public static function verifyPlatform(string $body, string $timestamp, string $signature, string $platformPublicKey): void
    {
        try {
            $origin = hash('sha512', $body) . self::timestamp($timestamp);
            $key = self::key($platformPublicKey, false);
            $sig = self::base64($signature, 'Signature');
            $details = openssl_pkey_get_details($key);
            if ($details === false) { throw new RuntimeException('OpenSSL public key details unavailable'); }
            $expected = intdiv($details['bits'] + 7, 8);
            if (strlen($sig) !== $expected) { throw new RuntimeException('Signature length: got ' . strlen($sig) . ', expected ' . $expected . ' bytes'); }
            $result = openssl_verify($origin, $sig, $key, OPENSSL_ALGO_SHA512);
            if ($result === 0) { throw new RuntimeException('RSA SHA512 verification failed: check platform key, raw bytes, timestamp and formula'); }
            if ($result !== 1) { throw new RuntimeException('OpenSSL RSA SHA512 verification error'); }
        } catch (Throwable $e) { self::logFailure('verify', $body, $e); throw $e; }
    }
}

HTTP 请求工具

HttpUtil.php

<?php
declare(strict_types=1);
require_once __DIR__ . '/SignUtil.php';

function send(string $baseUrl, string $path, string $merchantCode, ?string $rawBody, string $merchantPrivateKey): string
{
    if (!str_starts_with($baseUrl, 'https://')) { throw new RuntimeException('HTTPS is required'); }
    if (preg_match('/[\r\n]/', $merchantCode)) { throw new RuntimeException('Invalid merchant code'); }
    $timestamp = (string)time();
    $signature = SignUtil::signRequest($path, $rawBody, $timestamp, $merchantPrivateKey);
    $curl = curl_init($baseUrl . $path);
    if ($curl === false) { throw new RuntimeException('Cannot initialize cURL'); }
    $response = '';
    try {
        curl_setopt_array($curl, [
            CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 60,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Merchant-Code: ' . $merchantCode,
                'Timestamp: ' . $timestamp, 'Signature: ' . $signature],
            CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$response): int {
                if (strlen($response) + strlen($chunk) > 8 * 1024 * 1024) { return 0; }
                $response .= $chunk;
                return strlen($chunk);
            },
        ]);
        if ($rawBody !== null) { curl_setopt($curl, CURLOPT_POST, true); curl_setopt($curl, CURLOPT_POSTFIELDS, $rawBody); }
        if (curl_exec($curl) === false) { throw new RuntimeException('HTTP transport or response size error: cURL code ' . curl_errno($curl)); }
        $status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
        if ($status < 200 || $status >= 300) { throw new RuntimeException('HTTP ' . $status . '; reconcile before retrying'); }
        return $response; // Preserve raw response for envelope verification.
    } finally { curl_close($curl); }
}

接入示例

以下片段放在接入方应用中使用,payload、可信配置和事务业务处理函数由接入方提供;示例不会自动发起真实下单。

require_once 'HttpUtil.php';
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$responseBytes = send($baseUrl, '/v1/merchant/pay', $merchantCode, $body, $merchantPrivateKey);
// GET: send($baseUrl, '/v1/merchant/account', $merchantCode, null, $merchantPrivateKey);
// Verify the signed response data before using it. Do not treat HTTP 200 as payment success.
require_once 'SignUtil.php';
// Enforce an ingress body limit as well. Read at most 1 MiB + 1 byte.
$input = fopen('php://input', 'rb');
if ($input === false) { http_response_code(400); exit; }
$body = stream_get_contents($input, (1 << 20) + 1);
fclose($input);
if ($body === false || strlen($body) > (1 << 20)) { http_response_code(413); exit; }
try {
    SignUtil::verifyPlatform($body, $_SERVER['HTTP_TIMESTAMP'] ?? '',
        $_SERVER['HTTP_SIGNATURE'] ?? '', $configuredPlatformPublicKey);
} catch (Throwable $e) { http_response_code(401); exit('invalid signature'); }
$event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
validateAndApplyIdempotently($event); // Your durable business transaction.
echo 'SUCCESS';
最近更新: 2026/9/22 14:41
Contributors: aa, aaa