签名
算法说明
本系统采用 HMAC-SHA256 + JCS (RFC 8785) JSON 规范化 作为签名方案, 解决不同语言、不同 JSON 序列化器在键顺序、空格、Unicode 转义、数值精度等差异导致的签名失败问题。
官方规范:
- RFC 8785 — JSON Canonicalization Scheme (JCS)
- RFC 2104 — HMAC: Keyed-Hashing for Message Authentication
- FIPS 180-4 — SHA-256
完整签名步骤
- 从请求或返回的 JSON 对象中移除
sign字段(若存在) - 对剩余对象执行 JCS (RFC 8785) 规范化序列化,得到 UTF-8 字节数组
signingBytes- 键按 UTF-16 code-unit 字典序升序排列
- 不插入任何空格、换行、缩进
- 数值按 IEEE 754 round-trip 最短十进制表示
- 仅 JCS 规定的控制字符做
\uXXXX转义,其余字符(含中文、Emoji)保留原样 null、true、false、数组等所有 JSON 原生类型按 JCS 标准原样参与
- 以
UTF8(secretKey)为密钥,signingBytes为消息,做 HMAC-SHA256 运算 - 将 HMAC 结果(32 字节)做 Base64 标准编码,得到最终的
sign字符串 - 把
sign字段放回原始 JSON,提交或返回
用公式表达:
signingBytes = UTF8( JCS( payload_without_sign ) )
signature = Base64( HMAC-SHA256( UTF8(secretKey), signingBytes ) )重要规则
- 只有
sign字段在 JCS 规范化前被移除;其余字段(包括值为null、false、0、""的字段)按原样参与签名 secretKey仅作为 HMAC 的 key 参数传入;不要把它放进 JSON 做 JCS,也不要做 key/message 反转- 最终
sign是 Base64 标准编码(使用+/=,非 URL-Safe,无换行) - 验签时:对收到的
sign做Trim,解码后与期望值做恒定时间比较,避免时序攻击 - 验签需容忍响应中出现的新扩展字段——直接对整包重做 JCS + HMAC 即可,无需白名单过滤
签名示例
以下示例使用密钥 mvpay_demo_secret_0123,完整对应上一节的 5 个步骤。
Step 1 — 原始请求(签名前,尚未包含 sign 字段):
注意其中 rtn_url: ""(空值)和 extra: null(null 值)都按原样保留,不会被过滤。
{
"mcode": "12345",
"amt": "50.00",
"tno": "T202608020001",
"rtn_url": "",
"extra": null,
"ip": "127.0.0.1",
"acct": { "type": "bankcard", "name": "A", "number": "001" }
}Step 2 — 移除 sign 字段: 本示例中原始数据尚未签名,因此数据保持不变(若从别处接收的已签名数据先验签,需先删掉里面的 sign 字段再走后续步骤)。
Step 3 — JCS 规范化结果(单行、所有层级的键按 UTF-16 code-unit 字典序重排,无任何空格/换行。extra: null 和 rtn_url: "" 均参与):
{"acct":{"name":"A","number":"001","type":"bankcard"},"amt":"50.00","extra":null,"ip":"127.0.0.1","mcode":"12345","rtn_url":"","tno":"T202608020001"}Step 4 — HMAC-SHA256(key = secretKey, msg = signingBytes) 再 Base64 标准编码,得到 sign 字符串:
mac := hmac.New(sha256.New, []byte("mvpay_demo_secret_0123"))
mac.Write([]byte(`{"acct":{"name":"A","number":"001","type":"bankcard"},"amt":"50.00","extra":null,"ip":"127.0.0.1","mcode":"12345","rtn_url":"","tno":"T202608020001"}`))
sign := base64.StdEncoding.EncodeToString(mac.Sum(nil))
// sign = "TKwGqnHzFy0BaME1rZryqJql8d1UOIAI2180dOM2VIc="Step 5 — 回填 sign,即最终提交给网关的 JSON:
{
"mcode": "12345",
"amt": "50.00",
"tno": "T202608020001",
"rtn_url": "",
"extra": null,
"ip": "127.0.0.1",
"acct": { "type": "bankcard", "name": "A", "number": "001" },
"sign": "TKwGqnHzFy0BaME1rZryqJql8d1UOIAI2180dOM2VIc="
}验签时同样按这 5 步走:对收到的 JSON(除 sign 外)重做 JCS + HMAC-SHA256,再与收到的
sign做恒定时间比较即可。
示例代码
各语言实现(使用官方/社区维护的 JCS 公开库)
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/gowebpki/jcs"
)
func Sign(payload map[string]any, secretKey string) (string, error) {
// 1. 移除 sign
data := map[string]any{}
for k, v := range payload {
if k == "sign" {
continue
}
data[k] = v
}
// 2. JSON 字节 → JCS Transform
canonical, err := jcs.Transform(json.RawMessage(mustMarshal(data)))
if err != nil {
return "", err
}
// 3. HMAC-SHA256 + Base64
mac := hmac.New(sha256.New, []byte(secretKey))
mac.Write(canonical)
return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
}
func mustMarshal(v any) []byte { b, _ := json.Marshal(v); return b }
func main() {
sig, _ := Sign(map[string]any{
"mcode": "12345",
"amt": "50.00",
"tno": "T202608020001",
"acct": map[string]any{"type": "bankcard", "name": "A", "number": "001"},
}, "your-secret-key")
fmt.Println(sig)
}Java
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.webpki.json.JsonCanonicalizer;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
public class JcsHmac {
static final ObjectMapper M = new ObjectMapper();
public static String sign(Map<String, Object> payload, String secretKey) throws Exception {
// 1. 移除顶层 sign
JsonNode root = M.valueToTree(payload);
if (root.isObject()) ((ObjectNode) root).remove("sign");
byte[] canonical = JsonCanonicalizer.canonicalize(M.writeValueAsBytes(root));
// 2. HMAC-SHA256 + Base64
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return Base64.getEncoder().encodeToString(mac.doFinal(canonical));
}
public static void main(String[] args) throws Exception {
System.out.println(sign(java.util.Map.of(
"mcode", "12345",
"amt", "50.00",
"tno", "T202608020001"
), "your-secret-key"));
}
}PHP
<?php
require 'vendor/autoload.php';
use Emnabs\JsonCanonicalization\JsonCanonicalizer;
function signJcsHmac(array $payload, string $secretKey): string
{
// 1. 移除 sign 字段
if (array_key_exists('sign', $payload)) {
unset($payload['sign']);
}
// 2. JCS 规范化 → HMAC-SHA256(raw binary) → Base64
$canonical = JsonCanonicalizer::canonicalize($payload);
return base64_encode(hash_hmac('sha256', $canonical, $secretKey, true));
}
echo signJcsHmac([
'mcode' => '12345',
'amt' => '50.00',
'tno' => 'T202608020001',
], 'your-secret-key');Python
import base64
import hmac
import hashlib
import json_canonicalization as jcs_lib
def sign_jcs_hmac(payload: dict, secret_key: str) -> str:
# 1. 移除 sign
cleaned = {k: v for k, v in payload.items() if k != "sign"}
# 2. JCS -> bytes, HMAC-SHA256 -> bytes, Base64
signing_bytes = jcs_lib.canonicalize(cleaned)
mac = hmac.new(secret_key.encode("utf-8"), signing_bytes, hashlib.sha256)
return base64.b64encode(mac.digest()).decode("ascii")
if __name__ == "__main__":
print(sign_jcs_hmac({
"mcode": "12345",
"amt": "50.00",
"tno": "T202608020001",
"acct": {"type": "bankcard", "name": "A", "number": "001"},
}, "your-secret-key"))CSharp
using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Webpki.JsonCanonicalizer;
public static class JcsHmacSigner
{
public static string Sign(object payload, string secretKey)
{
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(payload));
var root = doc.RootElement.Clone();
// 1. 移除顶层 sign 字段
var node = root.ValueKind == JsonValueKind.Object
? RemoveSign(JsonObject.Create(root))
: JsonNode.Parse(root.GetRawText());
byte[] canonical = JsonCanonicalizer.Serialize(node);
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey));
return Convert.ToBase64String(hmac.ComputeHash(canonical));
}
static JsonNode RemoveSign(JsonObject obj)
{
obj.Remove("sign");
return obj;
}
}Node.js
const { createHmac } = require('node:crypto')
const { canonicalize } = require('json-canonicalization') // RFC 8785 (JCS)
function signJcsHmac(payload, secretKey) {
// 1. 移除 sign 字段
const { sign, ...rest } = payload
// 2. JCS 规范化 → UTF-8 Buffer → HMAC-SHA256 → Base64
const signingBytes = Buffer.from(canonicalize(rest), 'utf-8')
return createHmac('sha256', secretKey)
.update(signingBytes)
.digest('base64')
}
console.log(signJcsHmac({
mcode: '12345',
amt: '50.00',
tno: 'T202608020001',
rtn_url: '',
extra: null,
ip: '127.0.0.1',
acct: { type: 'bankcard', name: 'A', number: '001' }
}, 'mvpay_demo_secret_0123'))校验工具
浏览器端本地签名计算器。填入 32 或 64 位 Key 与待签名 JSON,即可得到 JCS 规范化字符串以及回填 sign 后的最终数据。全程本地计算,数据不发出任何网络请求。