← 返回

国密实战(二):SM2 签名验签前后端对接全指南

作者:林 | 系列:国密实战 | 适合读者:Java 后端/架构师 + 前端开发者


一、背景与问题

在很多业务场景中,前端需要对请求参数进行签名,后端进行验签,以确保数据完整性和防篡改。传统方案多使用 RSA 签名,但在信创项目中,SM2 签名验签成为必选项。

典型场景

  • 前端提交表单数据,附带 SM2 签名
  • 后端验签通过后才处理业务逻辑
  • API 网关层面的请求签名校验
  • 合同/文档电子签章

前后端对接 SM2 签名验签的核心挑战:

  1. 库的选择:Java 端用什么库?前端用什么库?
  2. 密钥格式约定:私钥和公钥的格式必须统一
  3. userId 的一致性:SM2 签名有一个独特的 userId 参数,两端必须一致
  4. hash 参数的一致性:是否对消息做预哈希,两端必须统一
  5. 签名格式r || s 拼接 vs DER 编码,两端必须约定

本文将给出 Java(Hutool)+ TypeScript(sm-crypto)的完整对接方案。


二、SM2 签名验签原理

2.1 签名过程

SM2 签名过程比 ECDSA 多了一个 Z 值(用户标识哈希)的计算。完整流程如下:

输入参数

  • 待签名消息 M
  • 用户标识 ID(默认 "1234567812345678"
  • 私钥 d(256 位整数)
  • 公钥 P = d × G = (xA, yA)

步骤

  1. 计算用户标识哈希值 Z:

    Z = SM3(ENTL ‖ ID ‖ a ‖ b ‖ xG ‖ yG ‖ xA ‖ yA)

    其中 ENTL 是 ID 的比特长度(2 字节),a、b 是曲线参数,(xG, yG)是基点 G,(xA, yA)是公钥点。

  2. 拼接并哈希:e = SM3(Z ‖ M)

  3. 生成随机数 k(范围 [1, n-1]),计算椭圆曲线点:(x1, y1)= k × G

  4. 计算签名分量:

    • r = (e + x1) mod n
    • s = ((1 + d)⁻¹ × (k - r × d)) mod n
  5. 如果 r = 0 或 r + k = n,重新选择 k(概率极低)

  6. 签名结果为 (r, s),其中 r、s 各为 256 位整数

验签过程

  1. 计算 Z 和 e(同签名步骤 1-2)
  2. 计算 t = (r + s) mod n,如果 t = 0 则验签失败
  3. 计算椭圆曲线点(x1, y1)= s × G + t × P
  4. 计算 R = (e + x1) mod n
  5. 如果 R = r,验签通过

2.2 关键参数:userId

SM2 签名中的 userId(也叫 ZA 计算中的 ID)是一个重要的参数:

场景userId说明
默认值"1234567812345678"GM/T 0009-2012 规定的默认值
自定义按业务约定如用户身份证号、企业标识等
核心坑点:userId 不一致导致验签失败

前后端的 userId 必须完全一致,否则计算出的 Z 值不同,e 值不同,签名结果不同,验签必然失败。而且这个错误不会有任何报错提示,只会得到"验签不通过"。

排查方法:如果验签一直失败且确认密钥正确,首先检查两端的 userId 是否一致。

2.3 签名格式

SM2 签名结果 (r, s) 有两种常见编码方式:

格式说明长度
r || s两个 32 字节整数直接拼接64 字节 = 128 hex 字符
DER 编码ASN.1 SEQUENCE { INTEGER r, INTEGER s }可变长度(通常 70-72 字节)

r ‖ s 长度 = 32 + 32 = 64 bytes = 128 hex chars

DER 编码结构:

30 xx          -- SEQUENCE
   02 xx [r]   -- INTEGER r(32 字节,可能有前导 00)
   02 xx [s]   -- INTEGER s(32 字节,可能有前导 00)
为什么 DER 编码长度不固定?
ASN.1 DER 中的 INTEGER 是有符号大端序。如果 r 或 s 的更高字节 ≥ 0x80,需要在前面补 0x00 以避免被解释为负数。因此 DER 编码的签名长度通常在 70-72 字节之间。

三、方案选型

3.1 Java 端:Hutool

Hutool 是一个轻量级 Java 工具库,从 5.4.x 版本开始内置 SM2 签名验签支持,API 简洁,适合快速接入。

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-crypto</artifactId>
    <version>5.8.44</version>
</dependency>

Hutool 与前端库的默认值不能靠印象对齐,下面在固定版本下逐项列出差异。

3.2 前端:sm-crypto

sm-crypto 是一个纯 JavaScript 实现的国密算法库,支持 SM2/SM3/SM4,可直接在浏览器和 Node.js 中使用。

npm install sm-crypto@0.3.14
项目Hutool 5.8.44sm-crypto 0.3.14对接要求
签名格式固定宽度 `rs`
默认 userId12345678123456781234567812345678双方显式传入并固定编码
SM2 密文顺序C1C3C2C1C3C2加解密接口声明顺序
消息哈希由调用方式决定hash: true 时执行 SM3 与 ZA 流程测试向量固定原文、userId 与模式

默认行为可能随版本和 API 入口变化,生产代码应显式设置格式、userId、字符编码和 hash 模式,并用固定向量双向验签。


四、后端 Java 实现(Hutool)

4.1 Maven 依赖

<dependencies>
    <!-- Hutool 加密模块 -->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-crypto</artifactId>
        <version>5.8.44</version>
    </dependency>
    <!-- BouncyCastle(Hutool 底层依赖) -->
    <dependency>
        <groupId>org.bouncycastle</groupId>
        <artifactId>bcprov-jdk18on</artifactId>
        <version>1.84</version>
    </dependency>
</dependencies>

4.2 SM2 签名验签工具类

import cn.hutool.core.codec.Base64;
import cn.hutool.core.util.HexUtil;
import cn.hutool.crypto.SmUtil;
import cn.hutool.crypto.asymmetric.SM2;
import org.bouncycastle.jce.provider.BouncyCastleProvider;

import java.nio.charset.StandardCharsets;
import java.security.Security;

/**
 * SM2 签名验签工具类
 * 基于 Hutool 5.8.44 + BouncyCastle 1.84
 *
 * 默认行为:
 *   - userId: "1234567812345678"(GM/T 0009-2012 默认值)
 *   - 签名格式: r||s(128 hex 字符)
 *   - hash: false(不额外哈希,与 sm-crypto 的 hash: false 模式一致)
 */
public class Sm2SignUtil {

    static {
        // 注册 BouncyCastle Provider
        if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
            Security.addProvider(new BouncyCastleProvider());
        }
    }

    // 默认 userId(GM/T 0009-2012 规定的默认值)
    private static final String DEFAULT_USER_ID = "1234567812345678";

    /**
     * SM2 签名
     *
     * @param privateKeyHex 私钥 hex(64 字符)
     * @param message       待签名消息(明文字符串)
     * @param userId        用户标识(通常用默认值 "1234567812345678")
     * @return 签名结果 hex 字符串(128 字符,r||s 拼接格式)
     */
    public static String sign(String privateKeyHex, String message, String userId) {
        // 创建 SM2 签名对象:SmUtil.sm2(私钥, 公钥)
        SM2 sm2 = SmUtil.sm2(privateKeyHex, null);
        // 设置 userId(影响 Z 值计算)
        sm2.setUserId(userId.getBytes(StandardCharsets.UTF_8));

        // 对消息进行签名(Hutool 默认返回 r||s 格式字节数组)
        byte[] msgBytes = message.getBytes(StandardCharsets.UTF_8);
        byte[] signBytes = sm2.sign(msgBytes);

        // 转为 hex 字符串(128 字符)
        return HexUtil.encodeHexStr(signBytes);
    }

    /**
     * SM2 签名(使用默认 userId)
     *
     * @param privateKeyHex 私钥 hex(64 字符)
     * @param message       待签名消息
     * @return 签名结果 hex(128 字符)
     */
    public static String sign(String privateKeyHex, String message) {
        return sign(privateKeyHex, message, DEFAULT_USER_ID);
    }

    /**
     * SM2 验签
     *
     * @param publicKeyHex 公钥 hex(130 字符,04 开头)
     * @param message      原始消息
     * @param signHex      签名 hex(128 字符)
     * @param userId       用户标识
     * @return 验签是否通过
     */
    public static boolean verify(String publicKeyHex, String message, String signHex, String userId) {
        SM2 sm2 = SmUtil.sm2(null, publicKeyHex);
        sm2.setUserId(userId.getBytes(StandardCharsets.UTF_8));

        byte[] msgBytes = message.getBytes(StandardCharsets.UTF_8);
        byte[] signBytes = HexUtil.decodeHex(signHex);

        return sm2.verify(msgBytes, signBytes);
    }

    /**
     * SM2 验签(使用默认 userId)
     *
     * @param publicKeyHex 公钥 hex(130 字符,04 开头)
     * @param message      原始消息
     * @param signHex      签名 hex(128 字符)
     * @return 验签是否通过
     */
    public static boolean verify(String publicKeyHex, String message, String signHex) {
        return verify(publicKeyHex, message, signHex, DEFAULT_USER_ID);
    }

    /**
     * 生成 SM2 密钥对
     *
     * @return String[] {privateKeyHex, publicKeyHex}
     */
    public static String[] generateKeyPair() {
        SM2 sm2 = SmUtil.sm2();
        return new String[] {
            sm2.getPrivateKeyBase64(),
            sm2.getPublicKeyBase64()
        };
    }
}

4.3 Spring Boot 接口示例

import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/api/sm2")
public class Sm2SignController {

    // 后端持有的公钥(用于验签),从配置或 KeyStore 获取
    private static final String PUBLIC_KEY = "04xxxxxxxxxxxx..."; // 130 字符 hex

    /**
     * 验签接口
     * 前端传入消息和签名,后端验签
     *
     * 请求示例:
     * POST /api/sm2/verify
     * Content-Type: application/json
     * {
     *   "message": "Hello, SM2!",
     *   "signature": "aabbccdd...(128字符)"
     * }
     *
     * 响应示例:
     * { "code": 200, "valid": true, "message": "验签通过" }
     */
    @PostMapping("/verify")
    public Map<String, Object> verify(@RequestBody Map<String, String> request) {
        String message = request.get("message");
        String signature = request.get("signature");

        Map<String, Object> result = new HashMap<>();
        try {
            boolean valid = Sm2SignUtil.verify(PUBLIC_KEY, message, signature);
            result.put("code", 200);
            result.put("valid", valid);
            result.put("message", valid ? "验签通过" : "验签失败");
        } catch (Exception e) {
            result.put("code", 500);
            result.put("valid", false);
            result.put("message", "验签异常: " + e.getMessage());
        }
        return result;
    }
}

五、前端 TypeScript 实现(sm-crypto)

5.1 安装依赖

npm install sm-crypto@0.3.14
# TypeScript 类型支持(社区维护)
npm install @types/sm-crypto --save-dev
如果 @types/sm-crypto 不可用
sm-crypto 可能没有官方类型包,需要手动创建类型声明文件。见下文 5.2 节。

5.2 类型声明

如果没有 @types/sm-crypto,创建类型声明文件 src/types/sm-crypto.d.ts

declare module 'sm-crypto' {
  interface Sm2 {
    /**
     * SM2 签名
     * @param msg 待签名消息
     * @param privateKey 私钥 hex(64 字符)
     * @param options 配置项
     */
    doSignature(msg: string, privateKey: string, options?: {
      pointPool?: any[];   // 预计算点池(提升性能)
      der?: boolean;       // 是否使用 DER 编码格式
      hash?: boolean;      // 是否对消息预哈希(SM3)
      userId?: string;     // 用户标识
      publicKey?: string;  // 公钥(某些场景需要)
    }): string;

    /**
     * SM2 验签
     * @param msg 原始消息
     * @param sigValue 签名 hex
     * @param publicKey 公钥 hex
     * @param options 配置项
     */
    doVerifySignature(msg: string, sigValue: string, publicKey: string, options?: {
      pointPool?: any[];
      der?: boolean;
      hash?: boolean;
      userId?: string;
    }): boolean;

    /** SM2 加密 */
    doEncrypt(msg: string | number[], publicKey: string, cipherMode?: number): string;

    /** SM2 解密 */
    doDecrypt(cipherText: string, privateKey: string, cipherMode?: number): string | false;

    /** 生成密钥对 */
    generateKeyPairHex(): {
      privateKey: string;  // 64 字符 hex
      publicKey: string;   // 130 字符 hex(04 开头)
    };
  }

  export const sm2: Sm2;

  /** SM3 哈希 */
  export function sm3(msg: string | number[]): string;

  /** SM4 对称加密 */
  export const sm4: {
    encrypt(msg: string | number[], key: string | number[], options?: any): string;
    decrypt(cipherText: string | number[], key: string | number[], options?: any): string | false;
  };
}

5.3 SM2 签名工具类

// src/utils/sm2.ts
import { sm2 } from 'sm-crypto';

// 默认 userId(GM/T 0009-2012 规定,与 Hutool 保持一致)
const DEFAULT_USER_ID = '1234567812345678';

export interface Sm2KeyPair {
  privateKey: string; // 64 字符 hex
  publicKey: string;  // 130 字符 hex,04 开头
}

/**
 * 生成 SM2 密钥对
 */
export function generateKeyPair(): Sm2KeyPair {
  const keypair = sm2.generateKeyPairHex();
  return {
    privateKey: keypair.privateKey,  // 64 字符 hex
    publicKey: keypair.publicKey,    // 130 字符 hex,04 开头
  };
}

/**
 * SM2 签名
 *
 * @param message      待签名消息
 * @param privateKey   私钥 hex(64 字符)
 * @param userId       用户标识(默认 "1234567812345678")
 * @returns 签名结果 hex 字符串(128 字符,r||s 格式)
 */
export function sign(
  message: string,
  privateKey: string,
  userId: string = DEFAULT_USER_ID
): string {
  // hash: false 表示不对消息做额外哈希,与 Hutool 行为一致
  // sm-crypto 内部会计算 Z 值并用 SM3 哈希,不需要外部再哈希
  const signature = sm2.doSignature(message, privateKey, {
    hash: false,
    userId: userId,
  });
  return signature;
}

/**
 * SM2 验签
 *
 * @param message    原始消息
 * @param signature  签名 hex(128 字符)
 * @param publicKey  公钥 hex(130 字符,04 开头)
 * @param userId     用户标识
 * @returns 验签是否通过
 */
export function verify(
  message: string,
  signature: string,
  publicKey: string,
  userId: string = DEFAULT_USER_ID
): boolean {
  return sm2.doVerifySignature(message, signature, publicKey, {
    hash: false,
    userId: userId,
  });
}

/**
 * SM2 加密
 *
 * @param msg        明文
 * @param publicKey  公钥 hex(130 字符,04 开头)
 * @returns 密文 hex(C1C3C2 格式)
 */
export function encrypt(msg: string, publicKey: string): string {
  return sm2.doEncrypt(msg, publicKey, 1);  // 1 = C1C3C2
}

/**
 * SM2 解密
 *
 * @param cipherText 密文 hex
 * @param privateKey 私钥 hex(64 字符)
 * @returns 明文或 false(解密失败)
 */
export function decrypt(cipherText: string, privateKey: string): string | false {
  return sm2.doDecrypt(cipherText, privateKey, 1);  // 1 = C1C3C2
}
关于 hash 参数(重要!)
  • hash: true(sm-crypto 默认):先对消息做 SM3 哈希,再签名
  • hash: false:直接对原始消息签名(Hutool 的行为)

前后端必须统一。本文方案前后端都使用 hash: false,保持一致。

如果两端 hash 参数不一致,验签会永远失败且无报错提示。这是 SM2 前后端对接常见的坑。

5.4 React 组件示例

import React, { useState } from 'react';
import { sign, verify, generateKeyPair } from '../utils/sm2';

const Sm2SignDemo: React.FC = () => {
  const [message, setMessage] = useState('Hello, SM2!');
  const [signature, setSignature] = useState('');
  const [verifyResult, setVerifyResult] = useState<string>('');

  // 模拟前端持有的私钥(实际项目中应从安全存储获取)
  const PRIVATE_KEY = 'your_private_key_hex_64_chars';

  const handleSign = () => {
    const sig = sign(message, PRIVATE_KEY);
    setSignature(sig);
    console.log('签名结果:', sig);
  };

  const handleVerify = async () => {
    // 调用后端验签接口
    const response = await fetch('/api/sm2/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message, signature }),
    });
    const result = await response.json();
    setVerifyResult(result.message);
  };

  return (
    <div>
      <h2>SM2 签名验签演示</h2>
      <div>
        <label>消息:</label>
        <input value={message} onChange={e => setMessage(e.target.value)} />
      </div>
      <button onClick={handleSign}>前端签名</button>
      <div>
        <label>签名:</label>
        <textarea value={signature} readOnly rows={3} style={{ width: '100%' }} />
      </div>
      <button onClick={handleVerify}>后端验签</button>
      {verifyResult && <p>验签结果:{verifyResult}</p>}
    </div>
  );
};

export default Sm2SignDemo;

六、前后端联调完整流程

6.1 联调时序

前端                                          后端
  │                                            │
  │  1. 用户输入消息 M                          │
  │                                            │
  │  2. 用私钥签名                              │
  │     sig = SM2.sign(M, privateKey, userId)  │
  │                                            │
  │  3. 发送请求 ──────────────────────────────→│
  │     POST /api/sm2/verify                   │
  │     { message: M, signature: sig }         │
  │                                            │
  │                    4. 用公钥验签             │
  │                       valid = SM2.verify(   │
  │                         M, sig, pubKey,     │
  │                         userId)             │
  │                                            │
  │  5. 返回结果 ←──────────────────────────────│
  │     { code: 200, valid: true }             │
  │                                            │

6.2 实际请求示例

前端发起请求

curl -X POST http://localhost:8080/api/sm2/verify \
  -H "Content-Type: application/json" \
  -d '{
    "message": "{\"orderId\":\"20240101001\",\"amount\":9999.00}",
    "signature": "a1b2c3d4e5f6...(共128字符)...7890abcdef"
  }'

后端响应

{
  "code": 200,
  "valid": true,
  "message": "验签通过"
}

6.3 完整测试代码

Java 端测试:

public class Sm2SignTest {

    public static void main(String[] args) {
        // 测试密钥对(实际项目中从证书或配置获取)
        String privateKey = "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344";
        String publicKey = "04"
            + "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
            + "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344";

        String message = "Hello, SM2!";
        String userId = "1234567812345678";

        // 1. 签名
        String signature = Sm2SignUtil.sign(privateKey, message, userId);
        System.out.println("签名结果: " + signature);
        System.out.println("签名长度: " + signature.length() + " 字符");  // 128

        // 2. 验签(使用正确的公钥和 userId)
        boolean valid = Sm2SignUtil.verify(publicKey, message, signature, userId);
        System.out.println("验签结果(正确参数): " + valid);  // true

        // 3. 验签失败场景:错误的 userId
        boolean validWrongUserId = Sm2SignUtil.verify(publicKey, message, signature, "wrong_user_id");
        System.out.println("验签结果(错误userId): " + validWrongUserId);  // false

        // 4. 验签失败场景:篡改消息
        boolean validTampered = Sm2SignUtil.verify(publicKey, "Tampered message", signature, userId);
        System.out.println("验签结果(篡改消息): " + validTampered);  // false

        // 5. 验签失败场景:错误公钥
        String wrongPubKey = "04" + "00".repeat(64);
        boolean validWrongKey = Sm2SignUtil.verify(wrongPubKey, message, signature, userId);
        System.out.println("验签结果(错误公钥): " + validWrongKey);  // false
    }
}

TypeScript 端测试:

import { sign, verify, generateKeyPair } from './utils/sm2';

// 1. 生成密钥对
const keyPair = generateKeyPair();
console.log('私钥:', keyPair.privateKey, '长度:', keyPair.privateKey.length);   // 64
console.log('公钥:', keyPair.publicKey, '长度:', keyPair.publicKey.length);    // 130

// 2. 签名
const message = 'Hello, SM2!';
const signature = sign(message, keyPair.privateKey);
console.log('签名:', signature, '长度:', signature.length);  // 128

// 3. 本地验签
const valid = verify(message, signature, keyPair.publicKey);
console.log('本地验签:', valid);  // true

// 4. 篡改消息验签
const validTampered = verify('Tampered', signature, keyPair.publicKey);
console.log('篡改验签:', validTampered);  // false

七、关键注意事项详解

7.1 userId 一致性

项目Hutool 默认值sm-crypto 默认值是否一致
userId"1234567812345678""1234567812345678"

如果业务需要自定义 userId,两端都需要显式设置:

// Java 端
sm2.setUserId("custom_user_id".getBytes(StandardCharsets.UTF_8));
// TypeScript 端
sm2.doSignature(message, privateKey, {
  userId: 'custom_user_id',
});

7.2 hash 参数统一

这是容易被忽略的坑:

hash=true 行为hash=false 行为
Hutool对消息先做 SM3 哈希再签名直接对原始消息签名(默认
sm-crypto对消息先做 SM3 哈希再签名(默认直接对原始消息签名

⚠️ 必须统一:要么两端都 hash: true,要么都 hash: false。本文方案采用 hash: false

建议:在项目文档和代码注释中明确记录 hash 参数的选择,避免后续维护者踩坑。

7.3 签名格式统一

默认签名格式DER 支持
Hutoolr || s(128 hex 字符)需手动转换
sm-cryptor || s(128 hex 字符)der: true 参数

本文方案使用默认的 r || s 格式,两端一致。

格式转换(如果需要 DER 格式):

// r||s → DER
public static String rsToDer(String rsHex) {
    byte[] rs = HexUtil.decodeHex(rsHex);
    byte[] r = new byte[32];
    byte[] s = new byte[32];
    System.arraycopy(rs, 0, r, 0, 32);
    System.arraycopy(rs, 32, s, 0, 32);

    ASN1EncodableVector v = new ASN1EncodableVector();
    v.add(new ASN1Integer(new BigInteger(1, r)));  // 注意:BigInteger(1, ...) 避免负数
    v.add(new ASN1Integer(new BigInteger(1, s)));
    return HexUtil.encodeHexStr(new DERSequence(v).getEncoded());
}

7.4 字符编码

签名时消息的字节编码必须一致:

// Java 端:显式使用 UTF-8
byte[] msgBytes = message.getBytes(StandardCharsets.UTF_8);
// TypeScript 端:JavaScript 字符串默认 UTF-16
// sm-crypto 内部会处理编码,通常不需要额外处理
// 但如果消息包含中文,确保两端编码一致

八、踩坑总结

坑点现象原因解决方案
userId 不一致验签永远失败,无报错SM2 签名的 Z 值计算依赖 userId两端统一使用 "1234567812345678"
hash 参数不一致验签永远失败消息处理方式不同两端统一使用 hash: false
签名格式不一致验签失败r||s vs DER两端统一使用 r||s 格式
公钥缺少 04 前缀解码报错 Invalid point encodingsm-crypto 要求完整格式补齐 04 前缀
私钥前导零丢失签名结果错误BigInteger 去掉了前导零补齐到 64 字符
编码不一致验签失败Java 和 JS 的字符串编码差异明确使用 UTF-8
中文签名不一致验签失败Unicode 规范化差异对消息做 NFC 规范化

九、安全注意事项

  1. 私钥保护:前端私钥不应硬编码在 JS 中,建议使用 Web Crypto API 或 HSM 方案
  2. 传输安全:签名验签请求必须走 HTTPS,防止中间人篡改
  3. 重放攻击:签名中应包含时间戳和 nonce,后端校验时效性
  4. 密钥轮换:定期更换密钥对,支持多版本公钥并存
  5. 日志脱敏:日志中不打印完整私钥和签名,只记录关键信息
// 重放攻击防护示例
@PostMapping("/verify")
public Map<String, Object> verify(@RequestBody SignRequest request) {
    // 1. 检查时间戳(5 分钟内有效)
    long now = System.currentTimeMillis();
    if (Math.abs(now - request.getTimestamp()) > 5 * 60 * 1000) {
        return Map.of("code", 403, "message", "签名已过期");
    }
    // 2. 检查 nonce 是否已使用(Redis 缓存)
    if (nonceCache.exists(request.getNonce())) {
        return Map.of("code", 403, "message", "签名已使用");
    }
    nonceCache.set(request.getNonce(), "1", 5, TimeUnit.MINUTES);
    // 3. 验签
    boolean valid = Sm2SignUtil.verify(PUBLIC_KEY, request.getMessage(), request.getSignature());
    return Map.of("code", 200, "valid", valid);
}

十、总结与成熟实践

  1. 库的选择:Java 端推荐 Hutool(简洁易用),前端推荐 sm-crypto(纯 JS,无原生依赖)
  2. 默认参数优先:使用默认的 userId="1234567812345678"r||s 签名格式,减少出错概率
  3. hash 参数统一:这是容易被忽略的坑,前后端必须统一 hash: falsehash: true
  4. 密钥格式统一:私钥 64 字符 hex,公钥 130 字符 hex(04 开头)
  5. 先本地验签,再联调:分别在前后端独立验签通过后,再进行跨端联调
  6. 测试异常场景:篡改消息、错误 userId、错误公钥等,确保验签能正确拒绝
  7. 防重放:签名中加入时间戳 + nonce,后端校验时效性

十一、参考资料


上一篇国密实战(一):SM2 密钥格式与 PFX 证书解析全指南

下一篇预告国密实战(三):SM2+SM4 数字信封完整实现(对接国企实战)