ajaxjs / query-sign-php
跨语言一致的请求数据签名(HMAC)库。PHP 实现,与 npm 包 query-sign-node 产生完全相同的签名。
Requires
- php: ^7.4 || ^8.0
- ext-hash: *
- ext-json: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-08 03:47:12 UTC
README
跨语言一致的请求数据签名(HMAC)库 —— PHP 实现。
同一份数据、同一个密钥,本包与 npm 版本 query-sign-node
产出的签名逐字节相同,因此可以放心用于「一端签名、另一端验签」的场景。
特性
- 跨语言一致:与 Node.js 实现共享同一份规范(SPEC.md)和黄金用例集, 另有随机模糊交叉验证逐字节比对
- 零依赖:只用
hash_hmac/rawurlencode等标准函数,仅要求ext-hash、ext-json - PSR-4 自动加载,命名空间
QuerySign\ - 嵌套结构自动扁平化,采用
a[b][0]=v风格的键名 - 确定性强:与数组键的插入顺序无关,键名按 UTF-8 字节序排序
- 常量时间比较验签(
hash_equals),不泄露签名长度以外的信息 - 快速失败:对象、资源、
NAN、INF等无法跨语言表示的值一律抛异常, 绝不静默产出签名 - 浮点数格式化严格复刻 ECMA-262
Number::toString(),与 JS 的String(n)输出一致
安装
composer require ajaxjs/query-sign-php
要求 PHP ^7.4 || ^8.0。
没有 Composer 时,本包只依赖 PSR-4 约定,可以自行注册加载器:
spl_autoload_register(static function (string $class): void { $prefix = 'QuerySign\\'; if (strncmp($class, $prefix, strlen($prefix)) !== 0) { return; } $file = __DIR__ . '/vendor/ajaxjs/query-sign-php/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; if (is_file($file)) { require $file; } });
快速开始
<?php use QuerySign\QuerySign; $qs = new QuerySign(['secret' => 'my-secret-key']); $qs->data([ 'appId' => 'wx1234567890', 'ts' => 1700000000, 'nonce' => 'a1b2c3', 'user' => ['id' => 7, 'name' => 'tom'], ]); $qs->getStringToSign(); // => 'appId=wx1234567890&nonce=a1b2c3&ts=1700000000&user%5Bid%5D=7&user%5Bname%5D=tom' $qs->getSign(); // => 'aca22e904d66fb82b613f97e2867e9e65270d4cde0f9aa62236798d450e50e71' (string) $qs; // 等价于 $qs->toString() // => 'appId=wx1234567890&nonce=a1b2c3&ts=1700000000&user%5Bid%5D=7&user%5Bname%5D=tom&sign=aca22e904d66fb82b613f97e2867e9e65270d4cde0f9aa62236798d450e50e71' $qs->sign(); // => ['appId' => 'wx1234567890', 'ts' => 1700000000, 'nonce' => 'a1b2c3', // 'user' => ['id' => 7, 'name' => 'tom'], 'sign' => 'aca22e90...'] json_encode($qs->sign()); // => {"appId":"wx1234567890","ts":1700000000,"nonce":"a1b2c3","user":{"id":7,"name":"tom"},"sign":"aca22e904d66fb82b613f97e2867e9e65270d4cde0f9aa62236798d450e50e71"}
服务端验签
$qs = new QuerySign(['secret' => 'my-secret-key']); // 直接把整个请求参数丢进来即可 $qs->data($_GET); if (!$qs->verify()) { http_response_code(403); exit('invalid signature'); }
verify() 省略参数时会自动从数据里读取 signField(默认 sign)。
注意:该字段本身不参与签名计算,所以无需先把它剔除。
与 Node.js 端互验
同样的数据和密钥,Node.js 端产出完全相同的签名:
const qs = new QuerySign({ secret: 'my-secret-key' }); qs.data({ appId: 'wx1234567890', ts: 1700000000, nonce: 'a1b2c3', user: { id: 7, name: 'tom' }, }); qs.getSign(); // => 'aca22e904d66fb82b613f97e2867e9e65270d4cde0f9aa62236798d450e50e71'
API
new QuerySign(array $options)
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
secret |
string |
— | 必填,非空字符串。为空时构造即抛 InvalidArgumentException |
signField |
string |
'sign' |
签名字段名。该字段在顶层被排除,不参与签名 |
algorithm |
string |
'sha256' |
HMAC 算法。跨端使用请选两端都支持的:md5 / sha1 / sha224 / sha256 / sha384 / sha512 |
encoding |
'hex' | 'base64' |
'hex' |
签名输出编码 |
uppercase |
bool |
false |
是否把 hex 输出转大写。对 base64 静默忽略 |
loose |
bool |
false |
仅对 hex 生效:验签时忽略大小写(兼容对端返回大写 hex) |
构造时会立即用 hash_algos() 校验 algorithm 是否被当前运行时支持,不支持则抛异常——
避免等到第一次签名时才失败。
$qs->options(); // => ['secret' => 'my-secret-key', 'signField' => 'sign', 'algorithm' => 'sha256', // 'encoding' => 'hex', 'uppercase' => false, 'loose' => false]
实例方法
| 方法 | 返回 | 说明 |
|---|---|---|
data(?array $data = null) |
$this |
设置待签名数据(关联数组或索引数组);传 null 或省略参数表示清空。PHP 数组是值类型,赋值即完成拷贝 |
getData() |
array |
当前数据的副本 |
getStringToSign() |
string |
规范化后的待签名字符串。跨语言排查签名不一致时先看这个 |
getSign() |
string |
计算并返回签名字符串 |
sign() |
array |
返回「原始数据 + 签名字段」的新数组,不修改内部数据 |
verify($sign = null) |
bool |
验签。任何非法输入都返回 false,不抛异常 |
toString() |
string |
key=value&...&sign=签名,可直接作为 query string |
__toString() |
string |
PHP 习惯用法,(string) $qs 等价于 toString() |
options() |
array |
当前生效配置的副本(含默认值) |
常量
QuerySign::SPEC_VERSION; // => 1,规范版本号,跨语言实现必须一致 QuerySign::COMMON_ALGORITHMS; // => ['md5','sha1','sha224','sha256','sha384','sha512']
异常
| 异常 | 触发时机 |
|---|---|
QuerySign\Exception\InvalidArgumentException |
配置非法、数据含不支持的值类型、NAN / INF、扁平化后键名冲突 |
QuerySign\Exception\ExceptionInterface |
上述异常的标记接口,可用于一次性捕获本库所有异常 |
原生 \TypeError |
data() 收到非数组(如字符串、整数)。由形参类型 ?array 直接拒绝 |
InvalidArgumentException 继承自 SPL 的 \InvalidArgumentException,
业务方既可以捕获本类,也可以沿用 \InvalidArgumentException:
use QuerySign\Exception\InvalidArgumentException; try { $qs->data(['at' => new DateTime()]); } catch (InvalidArgumentException $e) { // query-sign: 不支持的值类型 object(DateTime) (路径: at),请先转换为关联数组/索引数组或字符串 }
想一网打尽本库异常时,捕获标记接口即可:
use QuerySign\Exception\ExceptionInterface; try { $sign = $qs->getSign(); } catch (ExceptionInterface | \TypeError $e) { // ... }
签名算法
完整规范见 SPEC.md,摘要如下:
- 剔除签名字段 —— 只剔除顶层键名严格等于
signField的字段,嵌套层级中的同名字段照常参与 - 扁平化 ——
['a' => ['b' => [1]]]→a[b][0] = 1;null的键被整体丢弃;空数组不产生任何键 - 字符串化 —— 布尔转
true/false(不是 PHP 默认的1/'');整数直接转;浮点数用最短往返表示;NAN/INF抛异常 - 排序 —— 键名按 UTF-8 字节的无符号字典序升序(用
strcmp做二进制安全比较,不是本地 collation) - 编码拼接 —— 键和值分别用
rawurlencode编码(空格是%20而非+),以&连接 - HMAC —— 对
stringToSign做hash_hmac,按encoding输出
顶层既可以是关联数组也可以是索引数组。索引数组按下标展开:[10, 20] → 0=10&1=20。
几个容易踩坑但本库已处理好的点:
$qs = new QuerySign(['secret' => 'k']); // null 被整体丢弃,0 / '' / false 照常参与 $qs->data(['a' => 1, 'b' => null, 'c' => ['d' => null, 'e' => 2], 'f' => [], 'g' => 0, 'h' => '', 'i' => false]); $qs->getStringToSign(); // => 'a=1&c%5Be%5D=2&g=0&h=&i=false' // 只有顶层的 sign 被剔除,嵌套的 sign 照常参与 $qs->data(['sign' => 'keep-me', 'x' => ['sign' => 'inner']]); $qs->getStringToSign(); // => 'x%5Bsign%5D=inner'
注意事项
小数一律传字符串
浮点数的十进制表示存在语言/版本差异风险。本库虽然复刻了 ECMA-262 的最短往返表示
(依赖 serialize_precision = -1,若运行环境改过该配置,库内部会临时切换并还原),
仍然强烈建议把小数当字符串传:
$qs->data(['amount' => '88.88']); // 推荐 $qs->data(['amount' => 88.88]); // 可用,但依赖两端浮点格式化完全一致
不支持的值会抛异常
以下类型无法跨语言一致地表示:
$qs->data(['at' => new DateTime()]); // ✗ 请自行 ->format(DATE_ATOM) $qs->data(['id' => $someObject]); // ✗ 请自行 (array) 或 get_object_vars() $qs->data(['fh' => fopen(...)]); // ✗ resource $qs->data(['n' => NAN]); // ✗
其中 DateTime、自定义对象等在 data() 阶段就会被 normalize() 拒绝;
NAN / INF 属于 float,会通过 data(),在 getSign() 时才抛异常。
Node.js 端的时机与此完全一致。
这是刻意设计:静默跳过或 (string) $value 会让两端算出不同签名,
而这类问题在线上极难定位。
数字键会被 PHP 转成 int
$data = json_decode('{"0":"x","1":"y","2":"z"}', true); // PHP 得到的是索引数组 [0 => 'x', 1 => 'y', 2 => 'z'],不是关联数组
这不是 bug,而是必须支持的场景:同一份 JSON 在 JS 里是对象。 两端都会把它当作列表展开,签名相同:
$qs = new QuerySign(['secret' => 'my-secret-key']); $qs->data(json_decode('{"0":"x","1":"y","2":"z"}', true)); $qs->getStringToSign(); // => '0=x&1=y&2=z' $qs->getSign(); // => 'cd9bcc4f7b83320e0bf846d1258ec36c753cbeb45751cad2c85faa8737a1745e'
sign() 恒返回关联数组
顶层是索引数组时,追加字符串签名字段后自然变为关联数组:
$qs = new QuerySign(['secret' => 'my-secret-key']); $qs->data([10, 20]); $qs->getStringToSign(); // => '0=10&1=20' $qs->getSign(); // => '10d990bdc1ac924da51e00f473cd16a980e2a3c76e2b0b838e64fce0722f6b36' json_encode($qs->sign()); // => {"0":10,"1":20,"sign":"10d990bdc1ac924da51e00f473cd16a980e2a3c76e2b0b838e64fce0722f6b36"}
与 Node.js 端的 JSON 形态一致。顺带地,verify() 在顶层是索引数组时读不到签名字段
(索引数组没有字符串键),因此会返回 false——两端行为相同。
toString() 的签名值也做了编码
hex 下是恒等变换;base64 下会把 + / = 转义,避免破坏 query string:
$qs = new QuerySign(['secret' => 'k', 'encoding' => 'base64']); $qs->data(['a' => 'x', 'b' => 1, 'c' => ['d' => [1, 2]]]); $qs->getSign(); // => 'e6u2DDb5kxLyBwzj6aPU1ywvhZ3fMgxH/XC2YijLgQc=' $qs->toString(); // => 'a=x&b=1&c%5Bd%5D%5B0%5D=1&c%5Bd%5D%5B1%5D=2&sign=e6u2DDb5kxLyBwzj6aPU1ywvhZ3fMgxH%2FXC2YijLgQc%3D'
getSign() 返回的是未编码的原始签名值,放进 JSON body 时用这个。
uppercase 与 loose 的配合
对端返回大写 hex 时,不必改对方的实现,开 loose 即可:
$qs = new QuerySign(['secret' => 'my-secret-key', 'loose' => true]); $qs->data(['a' => 1, 'sign' => 'BD2EE5E60CD0794933ADF88E60C8D764A2D073B5CE4BC27CE64D66B6EF4D5C34']); $qs->verify(); // => true(小写形式是 bd2ee5e60cd0794933adf88e60c8d764a2d073b5ce4bc27ce64d66b6ef4d5c34)
未开 loose 时同一个输入返回 false。
自定义签名字段名
$qs = new QuerySign(['secret' => 'my-secret-key', 'signField' => 'signature']); $qs->data(['a' => 1, 'b' => 2]); json_encode($qs->sign()); // => {"a":1,"b":2,"signature":"5e7316c24a005a818c9a98b0692c437be9577737df3c1ca3d9fbfa08556f88f6"}
开发
本仓库即包根目录(由 monorepo ajaxjs/query-sign
通过 git subtree split 拆分发布):
composer install composer run lint # 语法检查全部源文件 + composer validate --strict composer run test # 跑全部单测与跨语言 fixtures composer run check # 以上两步
未执行 composer install 也能跑测试:tests/bootstrap.php 在找不到
vendor/autoload.php 时会回退到内置的 PSR-4 加载器,测试框架本身也是零依赖自建的
(tests/Harness.php)。所以直接:
php tests/run.php
同样可以完整校验。
tests/与scripts/在.gitattributes里标记了export-ignore,因此composer require安装的产物不含它们;但git clone本仓库能拿到完整内容,开发不受影响。
跨语言一致性校验需要同时具备 Node 与 PHP 环境,只在 monorepo 根目录提供:
git clone https://github.com/ajaxjs/query-sign.git cd query-sign npm run verify # 两端构建 + 测试 + 跨语言模糊交叉验证 npm run cross-check # 只跑模糊交叉验证