在 Go 语言中实现 HMAC 签名验证,最推荐的做法是使用标准库 crypto/hmac 结合 sha256 算法,并在服务端使用恒定时间比较函数防止时序攻击,适用于开放 API 或内部服务间的身份鉴别。
先说结论:实现 HMAC 验证是为了防篡改和认证,配合时间戳或 Nonce 才能防重放,必须配合 HTTPS 传输才能构成完整的安全闭环。
- 先判断:确认业务场景是否需要防重放,单纯内网可信环境可能过度设计
- 优先做:统一签名字符串的拼接顺序,确保客户端与服务端生成逻辑完全一致
- 再验证:使用 hmac.Equal 进行比对,严禁直接使用 == 操作符比较字节切片
核心原理与代码实现
核心流程分为三步:客户端构造签名字符串并计算摘要,服务端收到请求后复现计算过程,最后比对结果。以下是完整的服务端 Handler 示例,包含时间戳校验与签名比对。
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"strconv"
"time"
)
const secretKey = "your-secret-key"
const maxTimeDiff = 5 * time.Minute
func VerifyHandler(w http.ResponseWriter, r *http.Request) {
// 1. 获取 Header
receivedSig := r.Header.Get("X-Signature")
timestampStr := r.Header.Get("X-Timestamp")
// 2. 校验时间戳
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
if err != nil || time.Now().Unix()-timestamp > int64(maxTimeDiff.Seconds()) {
http.Error(w, "Invalid timestamp", http.StatusForbidden)
return
}
// 3. 读取 Body (注意:读取后需缓存以便后续业务使用)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
// 4. 复现签名字符串 (需与客户端完全一致)
// 建议格式:Timestamp\nBodyHash 或 Timestamp\nBody
stringToSign := fmt.Sprintf("%d\n%s", timestamp, string(body))
// 5. 计算期望签名
h := hmac.New(sha256.New, []byte(secretKey))
h.Write([]byte(stringToSign))
expectedSig := hex.EncodeToString(h.Sum(nil))
// 6. 恒定时间比对
if !hmac.Equal([]byte(receivedSig), []byte(expectedSig)) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("Success"))
}客户端生成签名的辅助函数如下,确保拼接逻辑与服务端完全一致。
func GenerateSignature(secret, data string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(data))
return hex.EncodeToString(h.Sum(nil))
}如何验证签名生效
由于 curl 无法直接计算 HMAC 签名,建议编写一个简单的脚本生成签名,再配合 curl 发送请求。
步骤 1:生成签名(Python 示例)
保存为 gen_sig.py,用于生成当前时刻的有效签名。
import hmac, hashlib, time
secret = "your-secret-key"
timestamp = str(int(time.time()))
body = "request body content"
data = f"{timestamp}\n{body}"
sig = hmac.new(secret.encode(), data.encode(), hashlib.sha256).hexdigest()
print(f"Timestamp: {timestamp}")
print(f"Signature: {sig}")步骤 2:发送请求
运行脚本获取签名后,填入 curl 命令。
curl -X POST \
-H "X-Signature: 填入生成的签名" \
-H "X-Timestamp: 填入生成的时间戳" \
-d "request body content" \
https://api.example.com/data查看服务端日志,确认签名校验失败的请求被正确拦截,合法请求返回 200。
进阶:防重放 Nonce 机制
仅依赖时间戳窗口可能存在窗口期内的重放风险。建议引入 Nonce(一次性随机数),在服务端缓存已使用的 Nonce,过期时间等于时间戳窗口。
// 伪代码示例:结合 Redis 实现 Nonce 校验
nonce := r.Header.Get("X-Nonce")
if redis.Exists(nonce) {
return Error("Replay attack")
}
// 设置过期时间与时间戳窗口一致
redis.Set(nonce, "1", maxTimeDiff)
// 继续后续签名校验常见坑与排查
- 时间同步问题:服务器与客户端时间偏差过大会导致合法请求被拒,建议允许一定的误差窗口(如 5 分钟)。
- URL 编码差异:路径中的特殊字符在不同语言或库中编码可能不同,导致签名字符串不一致,建议统一使用 RFC 3986 标准。
- 密钥泄露:一旦密钥泄露,所有历史通信都可能被伪造,需具备密钥轮换机制。
- Body 读取:服务端读取 Body 用于签名校验后,后续业务逻辑可能无法再次读取,需使用 io.TeeReader 或缓存 Body。
参考来源
- Go 官方文档 - crypto/hmac 包说明,https://pkg.go.dev/crypto/hmac
- Go 官方文档 - crypto/subtle 包说明(恒定时间比较原理),https://pkg.go.dev/crypto/subtle