国资监管接口签名算法X-Auth-Sign参数排序和密钥轮换的坑

0 阅读10分钟

对接国资监管接口的时候,X-Auth-Sign签名是最容易出问题的地方。参数排序规则不对、密钥轮换没处理好,都会导致请求被拒。这篇文章把签名算法的实现细节和踩过的坑记录下来。

一、X-Auth-Sign签名机制概述

1. 为什么要签名

国资监管数据报送平台的接口要求每个请求都携带X-Auth-Sign签名字段。签名的作用有两个:

  • 身份认证:证明请求确实来自经过授权的企业

  • 防篡改:保证请求参数在传输过程中没有被修改

签名的原理不复杂:把请求参数按照规则拼接成字符串,用密钥做HMAC运算,然后把结果放在请求头里。服务端用同样的规则和密钥计算一遍,对比结果是否一致。

2. 签名算法的基本规则

国资监管数据报送平台的签名规则一般包括以下几个步骤:

  1. 提取所有业务参数(排除sign本身)

  2. 参数名按字典序排序

  3. 拼接成key1=value1&key2=value2的格式

  4. 在末尾追加密钥

  5. 做SM3或SHA-256哈希运算

  6. 将哈希值转为大写十六进制字符串

看起来简单,但每一步都有坑。

二、签名算法的代码实现

1. 基础签名方法

import java.security.MessageDigest;
import java.util.*;

public class SasacSignUtil {
    
    private static final String CHARSET = "UTF-8";
    
    /**
     * 生成X-Auth-Sign签名
     * 
     * @param params 请求参数
     * @param secretKey 密钥
     * @return 签名字符串(大写HEX)
     */
    public static String generateSign(Map<String, String> params, String secretKey) {
        // 第1步:过滤空值和sign参数
        Map<String, String> filteredParams = new LinkedHashMap<>();
        for (Map.Entry<String, String> entry : params.entrySet()) {
            String key = entry.getKey();
            String value = entry.getValue();
            if (value != null && !value.isEmpty() && !"sign".equals(key)) {
                filteredParams.put(key, value);
            }
        }
        
        // 第2步:参数名按字典序排序
        List<String> sortedKeys = new ArrayList<>(filteredParams.keySet());
        Collections.sort(sortedKeys);
        
        // 第3步:拼接参数字符串
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < sortedKeys.size(); i++) {
            if (i > 0) {
                sb.append("&");
            }
            String key = sortedKeys.get(i);
            String value = filteredParams.get(key);
            sb.append(key).append("=").append(value);
        }
        
        // 第4步:追加密钥
        sb.append("&key=").append(secretKey);
        
        // 第5步:SM3哈希(或SHA-256)
        String sign = sm3Hash(sb.toString());
        
        // 第6步:转大写
        return sign.toUpperCase();
    }
    
    /**
     * SM3哈希计算
     */
    private static String sm3Hash(String input) {
        try {
            // 使用Bouncy Castle的SM3
            org.bouncycastle.crypto.digests.SM3Digest digest = 
                new org.bouncycastle.crypto.digests.SM3Digest();
            byte[] data = input.getBytes(CHARSET);
            digest.update(data, 0, data.length);
            byte[] hash = new byte[digest.getDigestSize()];
            digest.doFinal(hash, 0);
            return bytesToHex(hash);
        } catch (Exception e) {
            throw new RuntimeException("SM3计算失败", e);
        }
    }
    
    /**
     * 字节数组转十六进制字符串
     */
    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
    
    /**
     * 验证签名
     */
    public static boolean verifySign(Map<String, String> params, String secretKey, 
                                      String receivedSign) {
        String computedSign = generateSign(params, secretKey);
        return computedSign.equals(receivedSign);
    }
}

2. 完整的请求构建

签名计算好之后,需要放到HTTP请求头中:

public class SasacApiClient {
    
    private final String baseUrl;
    private final String appId;
    private final String secretKey;
    
    public SasacReportResponse sendReport(SasacReportData data) throws Exception {
        // 构建请求参数
        Map<String, String> params = new TreeMap<>();  // TreeMap自动排序
        params.put("app_id", appId);
        params.put("enterprise_code", "10000123");
        params.put("report_type", "fixed_assets");
        params.put("batch_no", data.getBatchNo());
        params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
        params.put("nonce_str", UUID.randomUUID().toString().replace("-", ""));
        params.put("data", SM4CryptoUtil.encrypt(
            JSON.toJSONString(data), secretKey
        ));
        
        // 计算签名
        String sign = SasacSignUtil.generateSign(params, secretKey);
        params.put("sign", sign);
        
        // 发送HTTP请求
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(baseUrl + "/api/report/submit"))
            .header("Content-Type", "application/json")
            .header("X-Auth-Sign", sign)
            .header("X-Auth-AppId", appId)
            .header("X-Auth-Timestamp", params.get("timestamp"))
            .header("X-Auth-Nonce", params.get("nonce_str"))
            .POST(HttpRequest.BodyPublishers.ofString(JSON.toJSONString(params)))
            .build();
        
        HttpResponse<String> response = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build()
            .send(request, HttpResponse.BodyHandlers.ofString());
        
        // 处理响应
        if (response.statusCode() == 200) {
            return JSON.parseObject(response.body(), SasacReportResponse.class);
        } else {
            throw new SasacApiException(
                "接口返回异常: " + response.statusCode() + " " + response.body()
            );
        }
    }
}

三、参数排序的坑

1. 字典序不是自然序

参数排序要求按字典序(lexicographic order)排列,不是自然数字排序。比如:

  • 字典序:a1, a10, a2, a20(字符逐位比较)

  • 自�自然序:a1, a2, a10, a20(按数值大小排)

Java中Collections.sort()默认就是字典序,但如果参数名中包含数字,容易出现理解偏差:

// 错误理解:以为会按数字排序
params.put("item_1", "value1");
params.put("item_10", "value10");
params.put("item_2", "value2");

// Collections.sort结果:item_1, item_10, item_2
// 但服务端期望的可能是:item_1, item_2, item_10

这种歧义最好在对接初期和服务端确认清楚。

2. 嵌套对象和数组怎么排

如果参数中有嵌套的JSON对象或数组,排序规则会更复杂。常见的做法:

// 嵌套对象先JSON序列化再参与签名
public class NestedParamSignUtil {
    
    public static String generateSign(Map<String, Object> params, String secretKey) {
        // 统一把Object转成String
        Map<String, String> flatParams = new TreeMap<>();
        
        for (Map.Entry<String, Object> entry : params.entrySet()) {
            String key = entry.getKey();
            Object value = entry.getValue();
            
            if (value == null) continue;
            
            if (value instanceof String) {
                flatParams.put(key, (String) value);
            } else if (value instanceof Number) {
                flatParams.put(key, value.toString());
            } else if (value instanceof Collection) {
                // 集合类型:JSON序列化,保持原始顺序
                flatParams.put(key, JSON.toJSONString(value));
            } else if (value instanceof Map) {
                // Map类型:JSON序列化
                flatParams.put(key, JSON.toJSONString(value));
            } else {
                flatParams.put(key, value.toString());
            }
        }
        
        // 拼接签名串
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> entry : flatParams.entrySet()) {
            if (sb.length() > 0) sb.append("&");
            sb.append(entry.getKey()).append("=").append(entry.getValue());
        }
        sb.append("&key=").append(secretKey);
        
        return SasacSignUtil.sm3Hash(sb.toString()).toUpperCase();
    }
}

3. 编码问题

参数值包含中文或特殊字符时,编码不一致会导致签名校验失败。坑点在于:

// 问题代码:参数值直接拼接,编码可能不一致
sb.append(key).append("=").append(value);

// 正确做法:统一URL编码或明确指定编码
// 方案A:不做URL编码,直接UTF-8拼接(大部分接口的要求)
sb.append(key).append("=").append(value);

// 方案B:对value做URL编码(少数接口的要求)
sb.append(key).append("=").append(URLEncoder.encode(value, "UTF-8"));

具体用哪种方案,取决于服务端的要求。国资监管数据报送平台的接口通常要求不做URL编码,直接拼接原始值。

四、密钥轮换的实现

1. 为什么需要密钥轮换

长时间使用同一个密钥存在泄露风险。国资监管数据报送平台要求定期更换密钥,一般每90天轮换一次。密钥轮换的核心挑战是:旧密钥的请求还在处理中时不能强制切换。

2. 双密钥并行方案

密钥轮换的标准做法是设置一个过渡期,过渡期内新旧密钥都可用:

public class KeyRotationManager {
    
    private final KeyStore keyStore;
    private volatile KeyPair currentKey;
    private volatile KeyPair previousKey;  // 轮换后保留旧密钥一段时间
    
    // 密钥信息
    public static class KeyPair {
        private String keyId;      // 密钥版本号
        private String keyValue;   // 密钥值
        private Date createTime;
        private Date expireTime;
        private KeyStatus status;
    }
    
    public enum KeyStatus {
        ACTIVE,      // 当前使用
        GRACE,       // 宽限期(旧密钥,仍可验签)
        ARCHIVED     // 已归档,不再使用
    }
    
    /**
     * 获取当前密钥
     */
    public KeyPair getCurrentKey() {
        return currentKey;
    }
    
    /**
     * 验证签名时,先尝试当前密钥,再尝试旧密钥
     */
    public boolean verifySign(Map<String, String> params, String receivedSign, 
                               String keyId) {
        KeyPair keyToUse = null;
        
        // 优先使用指定版本的密钥
        if (keyId != null) {
            if (currentKey != null && currentKey.getKeyId().equals(keyId)) {
                keyToUse = currentKey;
            } else if (previousKey != null && previousKey.getKeyId().equals(keyId)) {
                keyToUse = previousKey;
            }
        } else {
            // 没有指定版本,默认用当前密钥
            keyToUse = currentKey;
        }
        
        if (keyToUse == null) {
            log.warn("找不到对应版本的密钥: {}", keyId);
            return false;
        }
        
        return SasacSignUtil.verifySign(params, keyToUse.getKeyValue(), receivedSign);
    }
    
    /**
     * 执行密钥轮换
     */
    @Transactional
    public void rotateKey() {
        // 生成新密钥
        String newKeyId = "KEY_" + System.currentTimeMillis();
        String newKeyValue = generateRandomKey(32);
        
        KeyPair newKey = new KeyPair();
        newKey.setKeyId(newKeyId);
        newKey.setKeyValue(newKeyValue);
        newKey.setCreateTime(new Date());
        Calendar cal = Calendar.getInstance();
        cal.add(Calendar.DAY_OF_MONTH, 90);
        newKey.setExpireTime(cal.getTime());
        newKey.setStatus(KeyStatus.ACTIVE);
        
        // 当前密钥变为旧密钥(进入宽限期)
        if (currentKey != null) {
            currentKey.setStatus(KeyStatus.GRACE);
            cal = Calendar.getInstance();
            cal.add(Calendar.DAY_OF_MONTH, 7);  // 7天宽限期
            currentKey.setExpireTime(cal.getTime());
            previousKey = currentKey;
            
            keyStore.update(currentKey);
        }
        
        // 新密钥生效
        currentKey = newKey;
        keyStore.save(newKey);
        
        log.info("密钥轮换完成: 新密钥={}, 旧密钥进入宽限期", newKeyId);
        
        // 通知相关系统
        notifyKeyChange(newKey, previousKey);
    }
    
    private String generateRandomKey(int length) {
        // 使用SecureRandom生成安全随机密钥
        java.security.SecureRandom random = new java.security.SecureRandom();
        byte[] bytes = new byte[length];
        random.nextBytes(bytes);
        return bytesToHex(bytes);
    }
    
    /**
     * 定时检查并清理过期密钥
     */
    @Scheduled(cron = "0 0 2 * * ?")  // 每天凌晨2点检查
    public void cleanExpiredKeys() {
        Date now = new Date();
        
        if (previousKey != null && previousKey.getExpireTime().before(now)) {
            previousKey.setStatus(KeyStatus.ARCHIVED);
            keyStore.update(previousKey);
            previousKey = null;
            log.info("旧密钥已归档: {}", previousKey);
        }
    }
}

3. 密钥信息的存储

-- 密钥管理表
CREATE TABLE api_secret_keys (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    key_id VARCHAR(64) NOT NULL COMMENT '密钥版本ID',
    key_value VARCHAR(256) NOT NULL COMMENT '密钥值(加密存储)',
    api_type VARCHAR(32) NOT NULL COMMENT '接口类型: SASAC/CUSTOM',
    status VARCHAR(16) NOT NULL COMMENT '状态: ACTIVE/GRACE/ARCHIVED',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expire_time DATETIME COMMENT '过期时间',
    archived_time DATETIME COMMENT '归档时间',
    create_by VARCHAR(64) DEFAULT 'SYSTEM',
    UNIQUE KEY uk_key_id (key_id),
    INDEX idx_status (status)
) COMMENT='API密钥管理表';

五、常见签名错误的排查

1. 排查工具

当签名校验失败的时候,首先需要确认客户端和服务端的签名串是否一致。建议在开发环境打印中间结果:

// 签名调试工具
public class SignDebugger {
    
    public static void debugSign(Map<String, String> params, String secretKey) {
        System.out.println("========== 签名调试 ==========");
        
        // 打印原始参数
        System.out.println("\n[1] 原始参数:");
        for (Map.Entry<String, String> entry : params.entrySet()) {
            System.out.println("    " + entry.getKey() + " = " + entry.getValue());
        }
        
        // 打印过滤后的参数
        Map<String, String> filtered = new LinkedHashMap<>();
        for (Map.Entry<String, String> entry : params.entrySet()) {
            if (entry.getValue() != null && !entry.getValue().isEmpty() 
                    && !"sign".equals(entry.getKey())) {
                filtered.put(entry.getKey(), entry.getValue());
            }
        }
        System.out.println("\n[2] 过滤后参数:");
        filtered.forEach((k, v) -> System.out.println("    " + k + " = " + v));
        
        // 打印排序后的参数
        List<String> keys = new ArrayList<>(filtered.keySet());
        Collections.sort(keys);
        System.out.println("\n[3] 排序后参数名:");
        keys.forEach(k -> System.out.println("    " + k));
        
        // 打印签名串
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < keys.size(); i++) {
            if (i > 0) sb.append("&");
            sb.append(keys.get(i)).append("=").append(filtered.get(keys.get(i)));
        }
        sb.append("&key=").append(secretKey);
        System.out.println("\n[4] 签名串:");
        System.out.println("    " + sb.toString());
        
        // 打印哈希结果
        String sign = SasacSignUtil.sm3Hash(sb.toString()).toUpperCase();
        System.out.println("\n[5] 最终签名:");
        System.out.println("    " + sign);
        System.out.println("\n==============================");
    }
    
    public static void main(String[] args) {
        Map<String, String> params = new HashMap<>();
        params.put("app_id", "SASAC_APP_001");
        params.put("enterprise_code", "10000123");
        params.put("batch_no", "BATCH20260815001");
        params.put("timestamp", "1692086400");
        params.put("nonce_str", "abc123def456");
        
        debugSign(params, "test_secret_key_2024");
    }
}

2. 常见错误对照

错误现象原因解决方案
签名结果完全不同密钥不一致或签名算法不对核对密钥值,确认使用SM3还是SHA-256
签名结果部分不匹配参数顺序不对确认排序规则,检查大小写
偶尔失败timestamp过期或nonce重复检查服务器时间同步,nonce缓存时间
轮换后失败旧密钥已过期但请求还在用旧密钥确保密钥宽限期设置合理

搭贝AI低代码平台在国资报送自动化场景中封装了完整的签名管理功能,包括密钥轮换、签名调试和错误诊断,使用时不需要手动拼接参数串。

常见问题

Q:签名一直校验不通过怎么办?

第一步是把客户端的签名串和服务端的签名串做diff。两个字符串放在一起逐字符比对,多一个空格、少一个换行都会导致不匹配。第二步确认密钥是否正确,有些接口会给沙箱环境和生产环境不同的密钥。第三步检查时间戳是否在有效范围内,一般接口允许的时间偏差在5分钟以内。如果以上都没问题,联系接口提供方确认签名算法版本是否有更新。

Q:密钥轮换时正在处理的请求会受影响吗?

不会。密钥轮换采用双密钥并行方案,轮换后旧密钥进入宽限期(通常7天),宽限期内旧密钥的签名仍然可以通过验证。请求中携带密钥版本号(keyId),服务端根据版本号选择对应的密钥验签。只要宽限期设置合理,就不会有请求因为密钥轮换而失败。

Q:nonce_str的作用是什么?一定要传吗?

nonce_str(随机字符串)的作用是防止重放攻击。服务端会缓存一段时间内收到的nonce_str,如果发现重复就拒绝请求。建议每次请求都传,使用UUID即可。nonce_str的缓存时间一般在10-30分钟,具体看接口文档。如果不传nonce_str,理论上存在请求被截获后重发的风险。

Q:穿透式监管数据采集中签名和加密是什么关系?

签名和加密是两个独立的步骤。加密保护的是数据内容不被窃取,签名保证的是数据来源可靠且未被篡改。在国资监管数据报送流程中,业务数据先用SM4加密,然后对包含加密后数据的参数集做签名,最后把加密数据和签名一起发送。服务端先验签确认请求来源,确认无误后再解密获取原始数据。搭贝AI低代码平台在处理穿透式监管数据采集时自动完成加密和签名的组合操作。