接口签名认证详解:从原理到代码

12 阅读22分钟

接口签名认证详解:从原理到代码

适用场景:两个系统之间对接,一方(下文称 A 系统)调用另一方(下文称 B 系统)的 HTTP 接口。 阅读前置:了解 HTTP 请求的基本概念即可,不需要密码学基础。

目录


一、先看一个问题:为什么不直接传密码?

假设 A 系统要调用 B 系统的接口下单。最直觉的做法是:

POST /api/order?user=sys-a&password=123456

这个做法有个致命问题:密码每次请求都在网络上裸奔。

请求会经过 Nginx、网关、日志系统、APM 探针……链路上任何一个环节留下了明文,密码就泄露了。更糟的是,密码通常是长期有效的——一旦泄露,攻击者可以永久冒充你。

那么把密码加密一下再传呢?

password = AES(123456)   // 固定密钥加密

也不行:加密结果是固定的,攻击者拿到这个密文照样可以原样重放。

于是就有了下面这套方案。


二、核心原理

2.1 两个凭证,一个公开一个保密

双方事先线下交换一对凭证(由"发一个工单,对方私下给你"这类方式提供):

凭证作用类比是否随请求传输
appKey身份标识用户名✅ 明文传
appSecret密钥密码❌ 永不传输

双方各自保存一份 appSecret,两边必须完全一致(这是对称密钥)。

2.2 签名 = 报文的"指纹"

调用方在发送前,用 appSecret 对本次请求的内容做一次运算,得到一个结果,叫做签名。

⚠️ 先强调一点:签名是摘要(指纹),不是加密。sign 里不含报文,也无法还原出报文。这一点很容易误解,详见 2.5。

服务端收到请求后,用自己那份 appSecret 把同样的运算重做一遍,比对两个结果是否一致。

这个方案依赖两个关键性质:

  1. 雪崩效应:报文里改动任意一个字节,算出的签名会完全不同 → 改数据必然被发现
  2. 不可伪造:不知道 appSecret 的人,算不出正确签名 → 冒充不了

所以服务端只要验签通过,就能同时确认两件事:"你确实是持有密钥的那一方" + "报文在传输途中一个字节都没被改"。

一次调用中,签名的输入和输出长这样:

输入(待签名字符串):
    amount=100.00&appKey=sys-a&nonce=a1b2c3d4&orderNo=SO20261008001&timestamp=1730000000

加盐(密钥):
    appSecret = "s3cr3t-x9f2..."

输出(签名):
    sign = "K7fQ2mZ0pL8vR3nX1wY6tA4cE5gH9jB2dF0sM7qW="

注意:appSecret 本身没有出现在"输入"里,它是作为密钥参与运算的。这正是它永不传输的原因。

2.3 完整流程

sequenceDiagram
    autonumber
    participant C as 调用方 (A 系统)
    participant S as 服务方 (B 系统)

    Note over C: 组装业务参数
    Note over C: 加 appKey/timestamp/nonce
    Note over C: 用 appSecret 计算 sign
    C->>S: 请求(appKey, timestamp, nonce, 业务参数, sign)
    Note over S: 用 appKey 查出对应的 appSecret
    Note over S: ① 校验 timestamp 是否在时间窗口内
    Note over S: ② 校验 nonce 是否已用过
    Note over S: ③ 重算 sign 并比对
    alt 全部通过
        S-->>C: 业务处理结果
    else 任一项失败
        S-->>C: 401 签名校验失败
    end

2.4 漏洞:签名永远相同

如果只用 appKey + appSecret,还有一个大漏洞:

同一个报文,算出的签名永远相同。

这意味着攻击者只要抓到一次合法请求(比如"给 A 转账 100 元"),就能把这段报文原样重发 1000 次。服务端每次验签都会通过,因为签名确实是对的。

这叫 重放攻击(Replay Attack)。

补丁就是后面的 timestamp 和 nonce 两个字段。


2.5 澄清:sign 是报文摘要,不是加密后的报文

这是一个非常常见的误解:以为 sign 里装的是加密后的报文,服务端把它解密就能拿到数据。

不是的。sign 里没有报文,报文还原不出来。

HMAC-SHA256 的输出是固定 32 字节,Base64 后只有 44 个字符:

报文:  { "orderNo": "SO20261008001", "amount": "100.00", ... }   ← 10 KB
sign:  K7fQ2mZ0pL8vR3nX1wY6tA4cE5gH9jB2dF0sM7qW=                  ← 44 字符

一个 10 KB 的报文不可能被压进 44 个字符再还原回来。这不是"难破解",而是信息量根本不够——数学上不可能。

因为它是单向摘要,不是加密:

报文  ────►  sign      ✅ 容易算
sign  ────►  报文      ❌ 不可能(信息已经丢了)
加密 vs 签名
加密(AES / RSA)签名(HMAC-SHA256)
输出长度与原文一样长固定 32 字节
可逆吗✅ 有密钥就能还原出原文❌ 永远还原不回来
目的保密——别人看不到内容证明——内容没被改过,且是你发的
类比保险箱(有钥匙就能取出里面的东西)火漆印(证明是谁封的,但印里没有信)

一句话记住:sign 是火漆印,不是保险箱。

那 body 还要不要传原始报文?

必须传,而且必须原样传。

因为服务端验签的方式是"重算一遍":

Map<String,String> params = readParams(req);   // ← 报文来自 body,不是来自 sign
params.remove("sign");                          // sign 自身不参与签名

String expected = ApiSignUtil.sign(params, appSecret);   // 用同样的规则重算
if (!ApiSignUtil.safeEquals(expected, sign)) {
    return reject("签名校验失败");
}

如果 body 里只有 sign,服务端手里就只有一个孤零零的字符串,没有任何东西可以拿去比对——验签这个动作根本无法执行。

反过来说:sign 单独拿出来是毫无意义的,它的全部价值只在于"能和重算结果对得上"。

顺带解释一个现象

这也说明了为什么"签名对不上"是对接中最常见的故障,而且往往只差一个空格或编码:

服务端是拿你传的报文重算的。你改动了报文的任何一个字节,重算结果就跟着变,验签必然失败。

这不是 bug,这正是机制在起作用——它恰好证明了"报文在传输途中没有被改动过"。

那报文在链路上是明文,不会泄露吗?

是明文(TLS 保护之外的部分)。签名不提供机密性——中间人能看到 amount=100.00 是多少,但改不了(改一个字节签名就对不上,服务端直接拒)。

需要"报文本身也不让人看见"时,那要靠加密,是另一件事,并且可以和签名叠加:

{
  "encryptedData": "<AES(原始报文)>",
  "sign": "<HMAC(encryptedData)>"
}

注意此时签名的对象变成了密文:服务端先验签(确认密文没被篡改),再解密拿到明文。

场景需要额外加密吗
内部系统对接,走内网 / HTTPS❌ 不需要,TLS 足够
报文含身份证、银行卡号等敏感信息⚠️ 可考虑(TLS 已覆盖,若担心对方落盘则加)
不信任传输链路(如经过第三方网关)✅ 需要
合规要求"端到端加密"✅ 需要

对于内网系统对接,通常不需要——TLS 做链路保密 + 签名做完整性与身份认证,各管一段就够了。


三、四个字段各解决什么问题

字段解决的问题攻击场景怎么做到的
appKey你是谁匿名伪造请求服务端据此查出对应密钥,查不到直接拒
sign报文有没有被改中间人把 金额=100 改成 金额=100000签名是报文内容的函数,改了就验不过
timestamp请求是不是太旧了抓到包,一小时后重放超出时间窗口(如 60 秒)直接拒
nonce请求是不是发过一遍了在 60 秒窗口内把同一请求连发 1000 次服务端记录窗口内用过的随机串,用一次作废

理解它们的分工,可以记成一句话:

appKey    解决「身份」   —— 你是谁
sign      解决「完整性」 —— 你发的内容没被改过
timestamp 解决「时效」   —— 这个请求是新鲜的
nonce     解决「唯一性」 —— 这个请求只发过一次

四个里 timestamp 是性价比最高的:零存储成本,却能挡住绝大多数重放。nonce 最贵(需要共享存储),只在高价值写接口上才必要——详见第八章。


四、签名算法怎么选

4.1 两种常见写法

写法 A:拼接密钥式(支付宝老版 API 的风格)

String raw  = appKey + timestamp + 业务参数 + appSecret;
String sign = MD5(raw).toUpperCase();

把 appSecret 直接拼在字符串末尾,然后做一次摘要。

写法 B:HMAC 式(推荐)

String raw  = 待签名字符串;                      // 不含 appSecret
String sign = Base64(HMAC-SHA256(raw, appSecret));  // appSecret 作为密钥

4.2 为什么推荐 HMAC-SHA256

维度写法 A(MD5 拼接)写法 B(HMAC-SHA256)
摘要算法强度MD5 已被证明存在实用碰撞SHA-256 目前安全
结构安全性拼接式存在长度扩展攻击风险HMAC 有严格的密钥/消息分离结构,抗此类攻击
性能略快完全够用(微秒级)
业界趋势逐步淘汰主流选择

结论:新对接一律用 HMAC-SHA256 + Base64。除非对方系统很老只支持 MD5,才被迫兼容——那就至少保证业务参数顺序、编码、空值规则严格统一。


五、完整代码示例

5.1 第一步:双方约定规则

这是整个方案里最容易出故障的地方。 签名规则必须写成文档,双方一字不差地实现。任何一处细节不一致,都会导致"签名永远对不上"。

约定示例(本文后续代码均按此实现):

  1. 参与签名的字段:所有请求参数,但不包括 sign 本身
  2. 排序:按参数名的 ASCII 码升序(Java 里就是 TreeMap<String,String> 的默认顺序)
  3. 空值处理:值为 null 或空字符串的参数 一律剔除,不参与拼接
  4. 拼接格式:k1=v1&k2=v2&k3=v3,没有结尾的 &
  5. 字符编码:统一 UTF-8
  6. 编码:参数值不做 URL 编码(用原始值拼接;如果走 form 表单传输,传输时可以编码,但计算签名时用原始值)
  7. 算法:Base64(HMAC-SHA256(待签名字符串, appSecret))
  8. 时间窗口:双方约定 60 秒
  9. nonce:32 位随机十六进制字符串

5.2 客户端:生成签名

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;

public final class ApiSignUtil {

    private ApiSignUtil() {}

    public static final String APP_KEY = "sys-a";
    public static final String APP_SECRET = "s3cr3t-x9f2k8m4p1q7w3e5";

    /** 生成签名 */
    public static String sign(Map<String, String> params, String appSecret) {
        return hmacSha256Base64(canonicalize(params), appSecret);
    }

    /**
     * 生成待签名字符串(客户端和服务端必须完全一致)
     * 规则:剔除 sign 与空值 → 按 key 升序 → 拼成 k=v&k=v
     */
    public static String canonicalize(Map<String, String> params) {
        TreeMap<String, String> sorted = new TreeMap<>();
        params.forEach((k, v) -> {
            if (k == null || "sign".equals(k)) return;  // sign 自身不参与签名
            if (v == null || v.isEmpty()) return;       // 空值剔除,双方必须一致
            sorted.put(k, v);
        });

        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> e : sorted.entrySet()) {
            if (sb.length() > 0) sb.append('&');
            sb.append(e.getKey()).append('=').append(e.getValue());
        }
        return sb.toString();
    }

    private static String hmacSha256Base64(String data, String secret) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(raw);
        } catch (Exception e) {
            throw new IllegalStateException("签名计算失败", e);
        }
    }

    /** 固定时间比较,防时序攻击 */
    public static boolean safeEquals(String a, String b) {
        if (a == null || b == null) return false;
        return MessageDigest.isEqual(
                a.getBytes(StandardCharsets.UTF_8),
                b.getBytes(StandardCharsets.UTF_8));
    }

    // ---------- 使用示例 ----------
    public static void main(String[] args) {
        Map<String, String> params = new HashMap<>();
        params.put("appKey",    APP_KEY);
        params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
        params.put("nonce",     UUID.randomUUID().toString().replace("-", ""));
        params.put("orderNo",   "SO20261008001");
        params.put("amount",    "100.00");

        // 注意:sign 必须在其他所有字段都放好之后,最后计算
        params.put("sign", sign(params, APP_SECRET));

        System.out.println(params);
        // 待签名字符串 = amount=100.00&appKey=sys-a&nonce=<32位>&orderNo=SO20261008001&timestamp=1730000000
    }
}

5.3 服务端:校验签名

服务端通常做成一个拦截器,在业务逻辑之前统一处理:

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.util.Map;

@Component
public class ApiSignInterceptor implements HandlerInterceptor {

    /** 时间窗口:60 秒 */
    private static final long WINDOW_SECONDS = 60;

    @Resource private AppKeyRepository    appKeyRepository;   // 按 appKey 查 appSecret
    @Resource private StringRedisTemplate redis;

    @Override
    public boolean preHandle(HttpServletRequest req,
                             HttpServletResponse resp,
                             Object handler) throws Exception {

        Map<String, String> params = readParams(req);   // 从 body / query 中取出所有参数

        String appKey    = params.get("appKey");
        String timestamp = params.get("timestamp");
        String nonce     = params.get("nonce");
        String sign      = params.get("sign");

        if (appKey == null || timestamp == null || nonce == null || sign == null) {
            return reject(resp, "缺少签名参数");
        }

        // ① 查密钥
        String appSecret = appKeyRepository.findSecret(appKey);
        if (appSecret == null) {
            return reject(resp, "appKey 无效");
        }

        // ② 校验时间窗口
        long ts;
        try {
            ts = Long.parseLong(timestamp);
        } catch (NumberFormatException e) {
            return reject(resp, "timestamp 格式错误");
        }
        long now = System.currentTimeMillis() / 1000;
        if (Math.abs(now - ts) > WINDOW_SECONDS) {
            return reject(resp, "请求已过期");
        }

        // ③ 验签(必须在 nonce 落库之前!)
        String expected = ApiSignUtil.sign(params, appSecret);
        if (!ApiSignUtil.safeEquals(expected, sign)) {
            return reject(resp, "签名校验失败");
        }

        // ④ nonce 去重
        String nonceKey = "api:nonce:" + appKey + ":" + nonce;
        Boolean first = redis.opsForValue()
                .setIfAbsent(nonceKey, "1", Duration.ofSeconds(WINDOW_SECONDS));
        if (!Boolean.TRUE.equals(first)) {
            return reject(resp, "重复请求");
        }

        return true;   // 全部通过,放行到业务逻辑
    }

    private boolean reject(HttpServletResponse resp, String msg) throws Exception {
        resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        resp.setContentType("application/json;charset=UTF-8");
        resp.getWriter().write("{\"code\":401,\"msg\":\"" + msg + "\"}");
        return false;
    }

    private Map<String, String> readParams(HttpServletRequest req) {
        // 实现要点:把 JSON body 和 query string 合并成 Map<String,String>
        // 若 body 是 JSON,需要先用 ContentCachingRequestWrapper 包装请求才能重复读取
        throw new UnsupportedOperationException("按项目实际情况实现");
    }
}

注册拦截器:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Resource private ApiSignInterceptor apiSignInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(apiSignInterceptor)
                .addPathPatterns("/openapi/**");   // 只对开放接口生效,别拦内部接口
    }
}

顺序很重要:先验签,再消费 nonce。 如果反过来,攻击者只要知道你的 appKey(这是公开的),就能疯狂发假请求,把 nonce 全占掉,导致正常的请求被判定为重复——这是一种低成本拒绝服务攻击。先验签,假请求在第 ③ 步就被挡掉了,根本走不到 ④。

5.4 nonce 去重的 Redis 实现

核心就是一行——SET key value NX EX 60,原子地将"检查"和"写入"合并:

Boolean first = redis.opsForValue()
        .setIfAbsent(nonceKey, "1", Duration.ofSeconds(WINDOW_SECONDS));
  • setIfAbsent = Redis 的 SET ... NX,key 不存在才写入
  • 返回 true → 首次出现,放行
  • 返回 false → 窗口内已用过,判定为重放

TTL 为什么只需要 60 秒?

因为 timestamp 窗口也是 60 秒。一旦请求的时间戳过期,第 ② 步就把它拒了,根本走不到 nonce 校验——这条 nonce 记录留着也永远不会再被查到。所以 nonce 的 TTL = timestamp 的窗口 即可,Redis 会自动淘汰。

内存占用是有界的:

峰值 key 数 = 窗口内 QPS × 窗口秒数
QPS窗口峰值 key 数Redis 内存(约 100 B/key)
10060 s6000~0.6 MB
100060 s60000~6 MB
1000300 s300000~30 MB

跟运行时长无关——跑一年和跑一天占用一样。

5.5 用 curl 手工验证

对接调试时,用脚本自己算一遍签名,比写代码快得多:

APP_KEY="sys-a"
APP_SECRET="s3cr3t-x9f2k8m4p1q7w3e5"
TS=$(date +%s)
NONCE=$(uuidgen | tr -d '-' | tr 'A-Z' 'a-z')

# 注意:参数必须按 ASCII 升序排列(amount < appKey < nonce < orderNo < timestamp)
PARAMS="amount=100.00&appKey=${APP_KEY}&nonce=${NONCE}&orderNo=SO20261008001&timestamp=${TS}"

# HMAC-SHA256 后 Base64
SIGN=$(printf '%s' "$PARAMS" | openssl dgst -sha256 -hmac "$APP_SECRET" -binary | base64)

echo "待签名串: $PARAMS"
echo "签  名  : $SIGN"

curl -X POST https://system-b.example.com/openapi/order \
  -H 'Content-Type: application/json' \
  -d "{
        \"appKey\":\"${APP_KEY}\",
        \"timestamp\":\"${TS}\",
        \"nonce\":\"${NONCE}\",
        \"orderNo\":\"SO20261008001\",
        \"amount\":\"100.00\",
        \"sign\":\"${SIGN}\"
      }"

排查技巧:签名对不上时,让服务端把收到的"待签名字符串"打日志(注意脱敏),和调用方本地算的对比。差异通常在空格、编码、空值这几处,一眼就能看出来。


六、为什么不能只靠 HTTPS

一个常见疑问:HTTPS 已经把整个链路加密了,为什么还要额外做签名?

因为二者保护的层次不同:

HTTPS (TLS)接口签名
保护范围传输链路报文内容本身
保护对象防止链路上被窃听/篡改防止报文在服务端内部被篡改,以及识别调用方身份
身份认证只验证"服务器是真的"(单向)验证"调用方是谁"
失效点请求到达网关被解密后,就是明文了签名一直有效直到被业务消费

具体来说,HTTPS 管不到这些:

  1. 调用方身份:普通 HTTPS 只做服务端单向认证,客户端是谁它不知道。双向 TLS(mTLS)能做到,但证书发放/吊销/轮换的运维成本远高于一个 appSecret。
  2. 服务端内部信任边界:请求被网关解密后,参数以明文在服务内部流转。签名让每一层都能独立验证报文未被篡改。
  3. 弱信任的开放平台场景:第三方接入方众多,需要"请求自带可验证凭证",而不是靠运维维护一堆证书。
  4. 可审计:每个请求携带独立签名,出问题可以精确追溯到具体报文。

结论:不是二选一,而是 HTTPS 做链路加密 + 签名做业务级认证,两者互补。


七、六个常见坑

坑 1:签名对不上(最常见的故障,占 90%)

几乎总是这几处不一致:

不一致点表现
参数排序方式不同完全对不上
空值处理不同(一方剔除、一方保留 "")有参数为空时才对不上
编码不同(UTF-8 vs GBK)有中文时才不对上
拼接多了/少了结尾的 &完全对不上
数字格式不同(100 vs 100.00)金额类字段对不上
数组序列化不同(a,b vs ["a","b"])有数组参数时才对不上

对策:服务端把待签名字符串打日志,直接对比。

坑 2:用 equals 比较签名(时序攻击)

普通字符串比较是逐字节短路的——发现第一个不同的字符就返回。攻击者可以通过测量响应时间,一个字节一个字节地爆破出正确签名。

必须用固定时间比较:

// ❌ 错误
if (expected.equals(sign)) { ... }

// ✅ 正确
if (MessageDigest.isEqual(expected.getBytes(UTF_8), sign.getBytes(UTF_8))) { ... }

坑 3:服务器时钟不同步

客户端时间快 5 分钟,服务端窗口设 60 秒 → 所有请求都被判为过期。

对策:部署 NTP 时钟同步;窗口设为 60 秒(给时钟漂移留余量);返回错误时明确提示"请求已过期",方便定位。

坑 4:appSecret 硬编码进代码仓库

一旦进了 Git,就等于公开了。

对策:密钥放配置中心 / 密钥管理服务,环境变量注入。并且支持轮换——设计上允许同时存在新旧两把密钥(双密钥并存过渡期),否则换密钥需要双方同时停机。

坑 5:nonce 存在本地内存

服务有 3 个实例,请求被负载均衡轮询到不同实例 → 每个实例都认为自己是第一次见到这个 nonce → 去重完全失效。

对策:nonce 必须存共享存储(Redis),不能存 JVM 内存。

坑 6:先消费 nonce 再验签

假请求也能占掉 nonce 名额,形成拒绝服务。必须先验签,再消费 nonce。


八、nonce 到底要不要存?

这是最常被质疑的一点:"那这个随机数岂不是服务端一直得存着?"

答案是:只存一个时间窗口,不是永久存(见 5.4)。但更值得说的是——很多场景根本不需要 nonce。

8.1 按场景判断

场景需要 nonce 吗原因
查询类接口(GET 订单、拉列表)❌ 不需要重放一个读请求没有任何危害,timestamp 挡住过期重放就够了
天然幂等的写接口(按外部单号 upsert)❌ 不需要业务层用唯一键已经挡了
有状态机保护的写接口(状态只能 A→B→C)⚠️ 弱需要重放通常会在状态校验时失败
非幂等的高价值写接口(扣款、发货、生成流水号)✅ 需要必须挡

现实中,支付宝老版 API 根本没有 nonce 字段,靠 timestamp + 业务幂等;微信支付 v3 的 nonce_str 主要作用是参与签名和排查问题,并非严格去重。严格意义的 nonce 去重是一道可选的加强防线。

8.2 更省的替代方案

方案 A:用签名本身当去重 key

不额外生成随机串,直接 SET api:dedup:{appKey}:{sign}。效果相同,存储开销一样,但协议更干净,还避免了客户端 nonce 生成不规范(用低熵随机数、甚至写死常量)导致的漏洞。

方案 B:交给业务幂等(最省,推荐作为主力)

要求所有写接口都带业务唯一键(外部单号、请求 ID),在数据库唯一索引或分布式锁那一层去重。

这是做系统集成本来就该有的东西——即使没有签名机制也需要它来防止业务重复。做好之后,nonce 的边际价值就很低了。

方案 C:单调递增序列号(O(1) 存储)

if (seq <= lastSeq) reject;
lastSeq = seq;

服务端只存一个数字。但代价明显:客户端多实例部署时需要分布式发号(又变成瓶颈),且网络乱序到达会误杀合法请求。REST 场景基本不用。

方案 D:布隆过滤器 — 内存极小,但无法删除单个元素,TTL 到期没法精确淘汰,误判会拒绝合法请求。支付类接口不可接受。

8.3 推荐组合

层次做法成本
必做appKey + timestamp(60s) + HMAC-SHA256零存储
必做写接口的业务幂等键(DB 唯一索引)一个索引
按需高价值写接口加 Redis nonce 去重,TTL 60s~几 MB
不要做给查询接口加 nonce纯浪费

九、进阶:非对称签名

对称密钥方案有一个固有特点:服务端也持有 appSecret,所以服务端理论上可以伪造调用方的签名。

在大多数内部系统对接里这不是问题(双方互信)。但有些场景需要更强的保证:

  • 不可否认性:调用方事后不能抵赖"我没发过这个请求"
  • 服务端不可伪造:服务端自己造不出调用方的签名
  • 一对多分发:一个服务端对接大量第三方,不想给每人维护一把共享密钥

这时改用非对称密钥:

对称(HMAC)非对称(RSA / SM2 / ECDSA)
密钥双方共享 appSecret调用方持私钥,服务方持公钥
签名HMAC(报文, appSecret)Sign(报文, 私钥)
验签重算一遍 HMACVerify(报文, sign, 公钥)
服务端能否伪造请求能不能
性能快(微秒级)慢 10–100 倍
密钥管理简单复杂(需要分发公钥、处理轮换)

选型建议:

  • 内部系统对接(如 A 系统 ↔ B 系统)→ 对称 HMAC,简单高效,是正确选择
  • 金融场景 / 需要强不可否认性 / 对外开放平台 → 考虑非对称

十、落地检查清单

对接新接口时,逐项确认:

协议约定

  • 签名规则写成文档,双方确认:排序、空值、编码、拼接格式、算法
  • 时间窗口明确(建议 60 秒)
  • nonce 生成规则明确(建议 32 位随机十六进制)
  • 错误码定义清晰(缺参数 / appKey 无效 / 已过期 / 签名错误 / 重复请求)

客户端

  • sign 字段在其他所有字段组装完成后才计算
  • timestamp 用秒级还是毫秒级,与对方一致
  • 时间源准确(NTP)

服务端

  • appKey 无效、时间过期、签名错误、重复请求,响应明确区分
  • 先验签,再消费 nonce
  • 签名比较用固定时间比较
  • nonce 存 Redis 共享存储,TTL = 时间窗口
  • 待签名字符串打日志(脱敏),便于排查对不上的问题
  • 拦截器只作用于开放接口路径(/openapi/**),不误伤内部接口

安全

  • appSecret 不进代码仓库,走配置中心
  • 支持密钥轮换(双密钥并存过渡)
  • 密钥传输走安全通道(线下 / 加密信道),绝不通过即时通讯明文发
  • 高价值写接口有业务幂等键

十一、一句话总结

签名方案的本质是:不传密码,传"密码的运算结果"。

  • appKey 告诉服务端"我是谁",服务端据此找到密钥
  • sign 让服务端能确认"报文没被改过,且你确实持有密钥"
  • timestamp 让旧请求作废,挡住重放
  • nonce 让窗口内的重复请求作废,挡住窗口内重放(按需使用,只存一个窗口)

为什么这样设计:因为对称密钥永不传输,攻击者既拿不到密钥,也改不了报文,还无法重放——一次性解决了身份认证、数据完整性、防重放三个问题,而成本只是几个字段和一次哈希运算。


相关文档