防篡改靠 HMAC 签名保证请求完整性,防重放靠时间戳 + 一次性 Nonce 保证新鲜与唯一。三者组合:客户端用共享密钥对”方法 + 路径 + 参数 + 正文 + 时间戳 + Nonce”拼接串做 HMAC-SHA256,服务端按相同规则重算并比对,同时校验时间窗口与 Nonce 去重。下文给出规范拼接方式、可运行代码与落地清单。
一、为什么 API Key 不够
API Key 或 OAuth Token 只能认证”谁在调用”,挡不住两类攻击:攻击者截获合法请求后改参数(篡改金额),或原样重发(重复下单扣款)。HMAC 签名让任何字段改动都导致验签失败,时间戳与 Nonce 再补上”新鲜 + 唯一”。
签名验证服务端清单要求:时间戳在小窗口内(如 ±300 秒)、Nonce 未用过、按客户端相同规则重建待签串、用 keyId 选密钥并做恒定时间比较。
二、待签串的规范拼接
不同客户端拼接顺序不一致会让签名忽对忽错,必须定义规范化(canonical)规则。一份 API 文档把待签串定为:HTTP 方法 + 路径含查询 + 时间戳(毫秒)+ 正文。更稳妥的做法是对查询参数与头部按字典序排序,并包含正文 SHA-256 摘要。
import hashlib, hmac, time, uuid, json
def build_canonical(method, path, query, body_bytes, ts, nonce):
qs = "&".join(f"{k}={query[k]}" for k in sorted(query))
body_hash = hashlib.sha256(body_bytes).hexdigest
return "\n".join([
method.upper, path, qs,
f"x-timestamp:{ts}", f"x-nonce:{nonce}",
body_hash
])
def sign(secret, canonical):
return hmac.new(secret.encode, canonical.encode, hashlib.sha256).hexdigest
三、客户端签名与发送
- 发送前生成
ts = 当前毫秒时间戳与nonce = uuid4; - 按规范拼接待签串,调用
sign得签名; - 把
X-Signature、X-Timestamp、X-Nonce放进请求头发出。
secret = "shared-secret-with-server"
body = json.dumps({"amount": 100, "currency": "USD"}).encode
ts, nonce = str(int(time.time*1000)), str(uuid.uuid4)
canonical = build_canonical("POST", "/v1/pay", {}, body, ts, nonce)
sig = sign(secret, canonical)
# 请求头: X-Signature=sig, X-Timestamp=ts, X-Nonce=nonce
四、服务端四步校验
服务端收到请求后按序执行,任意一步失败即驳回。
| 步骤 | 校验 | 失败动作 |
|---|---|---|
| 1 | ` | now – ts |
| 2 | Nonce 未使用过 | 400 重放 |
| 3 | 重算 HMAC 与 X-Signature 一致 |
401 篡改 |
| 4 | 恒定时间比较,存 Nonce 至 TTL | 通过 |
时间戳只挡长期重放,Nonce 挡窗口内重复,二者配合:时间戳限定 Nonce 缓存只需保留一个窗口,避免无限存储。高影响写接口再叠加幂等键 Idempotency-Key,相同键直接返回首次结果,让合法重试也安全。
func verify(r *Request, secret string) error {
if abs(time.Now.UnixMilli-r.Ts) > 300_000 {
return ErrExpired
}
if used := redis.SIsMember("nonce:"+r.ClientID, r.Nonce); used {
return ErrReplay
}
redis.SAdd("nonce:"+r.ClientID, r.Nonce)
redis.Expire("nonce:"+r.ClientID, 5*time.Minute)
expect := hmacSHA256(secret, r.Canonical)
if !hmac.Equal([]byte(expect), []byte(r.Sig)) { // 恒定时间
return ErrTampered
}
return nil
}
五、密钥与运维
用 SHA-256 或 SHA-512,禁用 MD5/SHA-1;定期轮换密钥,旧密钥保留短宽限期避免客户端中断;密钥按 keyId 分发,前端绝不放密钥。时钟漂移用 NTP 对齐,误差大时返回服务端时间让客户端校正。TLS 仍是基础——签名防篡改重放,但传输加密防窃听,二者互补而非替代。
常见问题(FAQ)
Q1:有 TLS 还要签名吗?
要。TLS 防窃听,但截获的合法请求仍可被重放,需签名兜底。
Q2:Nonce 要存多久?
只存时间窗口长度(如 5 分钟),过期即清,勿永久存。
Q3:时间戳窗口设多大?
按风险,高危险 300 秒,低危险可放宽,配合 NTP 对齐。