必须用hmac.new配合sha256,不能用sha256.sum256直接拼接密钥;需严格按约定顺序拼接data、utc时间戳校验、body_hash预计算、密钥清理与轮换,否则签名失效或存在重放风险。

签名生成必须用hmac.New配合sha256,不能用crypto/sha256.Sum256
直接调用sha256.Sum256算哈希再拼接密钥,等同于裸哈希,完全不满足HMAC定义——它缺少密钥参与的两次哈希嵌套。Go标准库的hmac.New才是正确入口,底层自动处理opad/ipad填充和两次哈希。
常见错误是把签名逻辑写成:
hash := sha256.Sum256([]byte(secret + data)) // ❌ 错误:不是HMAC
正确做法是:
h := hmac.New(sha256.New, []byte(secret)) h.Write([]byte(data)) signature := hex.EncodeToString(h.Sum(nil))
注意:secret应为服务端统一保管的字节切片,避免字符串隐式转换导致空格或BOM干扰;data必须严格按约定顺序拼接(如method|path|timestamp|nonce|body_hash),字段缺失或顺序错一位,签名就失效。
时间戳校验必须设maxAge且服务端用time.Now().UTC()
客户端传timestamp=1717023456,服务端若直接用time.Unix(t, 0)解析再比对time.Now(),会因本地时区或NTP漂移导致误拒。所有时间操作必须走UTC。
- 签名数据中
timestamp字段必须是秒级Unix时间戳(不是毫秒) - 服务端校验逻辑:
reqTime := time.Unix(timestamp, 0).UTC() if time.Since(reqTime) > 5*time.Minute { // maxAge硬限制 return errors.New("timestamp expired") } - 客户端发请求前必须同步NTP,或从可信时间源获取时间,不能依赖系统时钟
忽略时区或放宽maxAge超过5分钟,攻击者就能重放旧请求——HMAC本身防篡改但不防重放。
Body内容必须预计算body_hash,不能在中间件里读两次req.Body
http.Request.Body是单次读取流,签名验证中间件若先ioutil.ReadAll算哈希,后续业务Handler再读就会得到空字节。必须在读取前复制一份原始Body。
推荐做法:
- 用
io.TeeReader边读边写入bytes.Buffer,同时计算SHA256 - 或提前在反向代理层(如Envoy)注入
X-Body-Hash头,微服务只校验不计算 - 若必须服务端计算,务必用
req.Body = io.NopCloser(buf)恢复Body供后续使用
示例关键片段:
buf := &bytes.Buffer{}
tee := io.TeeReader(req.Body, buf)
hash := sha256.New()
io.Copy(hash, tee)
bodyHash := fmt.Sprintf("%x", hash.Sum(nil))
req.Body = io.NopCloser(buf)
密钥管理不能硬编码,要区分环境且避免os.Getenv直读
开发环境用dev-secret、生产环境用KMS解密的密文,这是底线。但更隐蔽的风险是:用os.Getenv("API_SECRET")读取后直接传给hmac.New——如果环境变量被注入空格或换行符,签名必然失败,且日志里还不好排查。
安全做法:
- 读取后用
strings.TrimSpace清理首尾空白 - 校验长度:HMAC-SHA256密钥建议≥32字节,太短易被暴力破解
- 禁止日志打印完整密钥,连
log.Printf("using secret len=%d", len(secret))都可能泄露长度信息
最麻烦的点其实是密钥轮换:旧密钥要保留一个窗口期(比如2小时)用于验证尚未过期的旧请求,新旧密钥并存逻辑得侵入签名验证主路径,容易漏掉边界条件。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











