This commit is contained in:
toom1996
2026-09-07 00:04:01 +08:00
parent 6a5a4378ab
commit 10d8a96e8c
29 changed files with 5349 additions and 0 deletions

60
internal/pkg/hmac/hmac.go Normal file
View File

@ -0,0 +1,60 @@
// Package hmac 提供爬虫 → 后台 ingest 接口的 HMAC-SHA256 请求签名与校验。
//
// 设计目标(与公开接口「前端 JS 签名」区分):
// - 这里面向「服务端到服务端」的爬虫上报,密钥绝不下发到任何前端 bundle,
// 只存在于爬虫配置与后台 INGEST_SECRET 环境变量,安全性高得多。
// - 通过 X-Signature / X-Timestamp / X-Nonce 三头防篡改 + 防重放:
// - X-Timestamp 容忍窗口(默认 ±5 分钟)挡掉过期请求;
// - X-Nonce 一次性随机串,由后台入库去重挡掉重放(见 repository.IngestRepository 的 nonce 表)。
//
// 签名串拼接:HMAC_SHA256(secret, timestamp + "." + nonce + "." + bodyRaw)
// bodyRaw 是请求体的原始字节(未编码),保证签名与服务端收到的字节严格一致。
package hmac
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
// DefaultTTL 签名时间戳默认容忍窗口(秒)。
const DefaultTTL = 300
// Sign 生成签名:对 timestamp.nonce.body 做 HMAC-SHA256,返回十六进制串。
func Sign(secret, timestamp, nonce, body string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write([]byte(nonce))
mac.Write([]byte("."))
mac.Write([]byte(body))
return hex.EncodeToString(mac.Sum(nil))
}
// Verify 校验请求签名,同时检查时间戳窗口。
//
// 返回 (ok, error):ok=false 表示签名或时间戳不通过;error 仅用于内部异常(理论上不会返回)。
// 注:nonce 防重放不在此处判断,交由调用方(中间件 / 仓储)查库,
// 因为 nonce 是否重复依赖持久化状态,且失败时应返回 409 而非 401。
func Verify(secret, body, sigHeader, tsHeader, nonceHeader string, ttl int) bool {
if sigHeader == "" || tsHeader == "" || nonceHeader == "" {
return false
}
if ttl <= 0 {
ttl = DefaultTTL
}
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return false
}
now := time.Now().Unix()
if diff := now - ts; diff > int64(ttl) || diff < -int64(ttl) {
return false
}
expected := Sign(secret, tsHeader, nonceHeader, body)
// 定长比较防时序侧信道
return hmac.Equal([]byte(expected), []byte(strings.TrimSpace(sigHeader)))
}

View File

@ -0,0 +1,78 @@
package hmac
import (
"testing"
"time"
)
func TestSignVerifyRoundTrip(t *testing.T) {
secret := "topsecret"
body := `{"brand_uid":"001DESke","source_url":"https://vogue.com/x"}`
ts := tsNow()
nonce := "abc123nonce"
sig := Sign(secret, ts, nonce, body)
if !Verify(secret, body, sig, ts, nonce, DefaultTTL) {
t.Fatal("valid signature should verify")
}
// 篡改 body → 失败
if Verify(secret, body+"x", sig, ts, nonce, DefaultTTL) {
t.Fatal("tampered body should fail")
}
// 错误密钥 → 失败
if Verify("wrong", body, sig, ts, nonce, DefaultTTL) {
t.Fatal("wrong secret should fail")
}
// 缺头 → 失败
if Verify(secret, body, "", ts, nonce, DefaultTTL) {
t.Fatal("missing header should fail")
}
}
func TestVerifyRejectsStaleTimestamp(t *testing.T) {
secret := "s"
body := "{}"
// 早于窗口
old := timeNowMinus(600)
nonce := "n1"
sig := Sign(secret, old, nonce, body)
if Verify(secret, body, sig, old, nonce, DefaultTTL) {
t.Fatal("stale timestamp should be rejected")
}
// 晚于窗口(未来太多)
future := timeNowPlus(600)
sig2 := Sign(secret, future, nonce, body)
if Verify(secret, body, sig2, future, nonce, DefaultTTL) {
t.Fatal("future timestamp beyond window should be rejected")
}
}
// 简易时间助手(避免直接依赖 time.Now 的不可控)
func tsNow() string { return itoa(int64(time.Now().Unix())) }
func timeNowMinus(sec int) string {
return itoa(int64(time.Now().Unix()) - int64(sec))
}
func timeNowPlus(sec int) string {
return itoa(int64(time.Now().Unix()) + int64(sec))
}
func itoa(v int64) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
buf := [20]byte{}
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}