接口请求签名与认证的两种方案对比

目录

  1. 为什么一定要做“签名 + 认证”
  2. 方案一:自定义 Ed25519 报文级签名
  3. 方案二:JWT(Ed25519 EdDSA)
  4. 维度对照总表
  5. 场景化选型建议
  6. 结语

为什么一定要做「签名 + 认证」

认证 (Authentication):谁在访问?
完整性 (Integrity):请求内容在传输途中是否被篡改?
抗重放 (Replay):同一帧数据会不会被复制多次发动攻击?

只要是在公网、跨团队或跨语言调用 REST/GraphQL/gRPC/WebSocket,甚至 IoT MQTT,都绕不开上面三个问题。最常见做法就是“在每个请求上做签名,再让服务端验签”。下面给出两套可以直接落地的方案。

方案一:自定义 Ed25519 报文级签名

工作原理

  1. 客户端保存 私钥(48 B PKCS#8 DER,再 base64 存储)。
  2. 每次请求:
    1. 取 UTC 秒级时间戳 timestamp
    2. bodyBytes = json.Marshal(body)
    3. msg = timestamp || bodyBytes
    4. signature = Ed25519.Sign(privKey, msg)
    5. 把 3 个头一并发出
      • X-Timestamp
      • X-Public-Key (base64 DER,44 B)
      • X-Signature (base64,64 B)
      • (可选)再叠一层 Basic Auth / mTLS
  3. 服务端:
    • 校验时间戳 ±N 秒
    • X-Public-Key 还原公钥
    • 重新拼 msg′ = timestamp || body,执行 ed25519.Verify(...)
    • 通过返回 200,失败直接 401/403

完整 Go 代码

客户端——生成头并发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// ---------------- 公共结构体 ----------------
type APIRequest struct {
Host string `json:"host"`
Username string `json:"username"`
Command string `json:"command"`
}

// ---------------- 计算签名 ----------------
type SignResult struct {
XTimestamp string
XPublicKey string
XSignature string
SignedBody []byte
}

func MakeEd25519Headers(privBase64 string, body APIRequest) (*SignResult, error) {
const (
privDERLen = 48
seedStartIdx = 16
pubPrefix = "MCowBQYDK2VwAyEA" // 12 B 固定前缀的 base64 表示
timestampFmt = "20060102150405"
)
der, err := base64.StdEncoding.DecodeString(privBase64)
if err != nil || len(der) != privDERLen {
return nil, fmt.Errorf("decode priv base64: %w", err)
}
seed := der[seedStartIdx:]
privKey := ed25519.NewKeyFromSeed(seed)
pubKey := privKey.Public().(ed25519.PublicKey)

bodyBytes, _ := json.Marshal(body)
ts := time.Now().UTC().Format(timestampFmt)
msg := append([]byte(ts), bodyBytes...)

sig := ed25519.Sign(privKey, msg)
pubDER, _ := base64.StdEncoding.DecodeString(pubPrefix)
pubDER = append(pubDER, pubKey...)

return &SignResult{
XTimestamp: ts,
XPublicKey: base64.StdEncoding.EncodeToString(pubDER),
XSignature: base64.StdEncoding.EncodeToString(sig),
SignedBody: bodyBytes,
}, nil
}

// ---------------- 发送 HTTP ----------------
func SendSignedRequest(url string, body APIRequest, sig *SignResult) (string, string, error) {
j, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", url, bytes.NewReader(j))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Timestamp", sig.XTimestamp)
req.Header.Set("X-Public-Key", sig.XPublicKey)
req.Header.Set("X-Signature", sig.XSignature)

cli := &http.Client{Timeout: 8 * time.Second}
resp, err := cli.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.Status, string(b), nil
}

服务端——验签中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func verifyEd25519(next http.Handler) http.Handler {
const skew = 30 * time.Second
const pubPrefixLen = 12

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tsStr := r.Header.Get("X-Timestamp")
pubStr := r.Header.Get("X-Public-Key")
sigStr := r.Header.Get("X-Signature")
if tsStr == "" || pubStr == "" || sigStr == "" {
http.Error(w, "missing hdr", 401); return
}

// 1. 时钟偏移
ts, _ := time.ParseInLocation("20060102150405", tsStr, time.UTC)
if d := time.Since(ts); d > skew || d < -skew {
http.Error(w, "expired", 401); return
}

// 2. 公钥 & 签名还原
pubDER, _ := base64.StdEncoding.DecodeString(pubStr)
if len(pubDER) <= pubPrefixLen {
http.Error(w, "pub too short", 401); return
}
pubKey := ed25519.PublicKey(pubDER[pubPrefixLen:])
sig, _ := base64.StdEncoding.DecodeString(sigStr)

// 3. 读取 body & 验签
body, _ := io.ReadAll(r.Body)
msg := append([]byte(tsStr), body...)
if !ed25519.Verify(pubKey, msg, sig) {
http.Error(w, "bad signature", 401); return
}

// 4. 复写 body 继续业务
r.Body = io.NopCloser(bytes.NewReader(body))
next.ServeHTTP(w, r)
})
}

优缺点小结

优点

  • 100 % Stateless,仅依赖 3 个自定义头
  • 报文极小(~108 B)
  • 仅依赖标准库 crypto/ed25519 + encoding/base64

缺点

  • 只能证明“这条报文来自这把公钥”,无法嵌入角色、过期等信息
  • 客户端换钥需要升级配置
  • 无跨语言标准,需要自己维护 SDK 和文档

方案二:JWT(Ed25519 EdDSA 签名)

工作原理

Token = base64url(header).base64url(payload).base64url(signature)

  • header:alg=EdDSA,可选 kid
  • payload:可存 sub(主体)、exp(过期)、role(角色)、tenant_id
  • signature:用同一把 Ed25519 私钥签名
  • 客户端用 Authorization: Bearer <token> 携带

完整 Go 代码

客户端——签出 JWT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// go get github.com/golang-jwt/jwt/v5
func IssueJWT(privBase64, subject string, ttl time.Duration) (string, error) {
const privDERLen = 48
const seedStartIdx = 16

der, _ := base64.StdEncoding.DecodeString(privBase64)
if len(der) != privDERLen {
return "", errors.New("bad key len")
}
seed := der[seedStartIdx:]
priv := ed25519.NewKeyFromSeed(seed)

claims := jwt.RegisteredClaims{
Subject: subject,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
Issuer: "cli-tool",
}
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
return tok.SignedString(priv)
}

客户端——发送请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func SendJWTRequest(url, jwtToken string, body APIRequest) (string, string, error) {
j, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", url, bytes.NewReader(j))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+jwtToken)

cli := &http.Client{Timeout: 8 * time.Second}
resp, err := cli.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.Status, string(b), nil
}

服务端——验证中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func jwtMiddleware(pubDERBase64 string, next http.Handler) http.Handler {
const pubPrefixLen = 12
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, "missing bearer", 401); return
}
tokenStr := strings.TrimPrefix(auth, "Bearer ")

// 1. 解析公钥
pubDER, _ := base64.StdEncoding.DecodeString(pubDERBase64)
if len(pubDER) <= pubPrefixLen {
http.Error(w, "bad pub", 401); return
}
pubKey := ed25519.PublicKey(pubDER[pubPrefixLen:])

// 2. Parse & Validate
t, err := jwt.Parse(tokenStr, func(tok *jwt.Token) (any, error) {
if tok.Method != jwt.SigningMethodEdDSA {
return nil, fmt.Errorf("unexpected alg")
}
return pubKey, nil
})
if err != nil || !t.Valid {
http.Error(w, "unauthorized: "+err.Error(), 401); return
}

// 3. 可把 claims 放进 ctx
ctx := context.WithValue(r.Context(), "claims", t.Claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

优缺点小结

优点

  • payload 可自由写租户、角色、过期、jti(可做黑名单)
  • kid + JWKS 支持不停机密钥轮换
  • 生态成熟,浏览器 / 移动端 / Python / Java … 全有库

缺点

  • Token 本身 200 ~ 800 B,占带宽
  • 依赖第三方库 github.com/golang-jwt/jwt/v5
  • 设计不当易造成权限泄漏(例如过期时间过长)

维度对照总表

维度 自定义 Ed25519 签名 JWT / EdDSA
认证对象 公钥 == 身份,或再叠 Basic/mTLS payload.sub / tenant / role 等
额外声明 几乎没有(可再造头) payload 任意字段
抗重放 依赖时间戳 ±窗口 exp / nbf / jti
Key Rotation 客户端手动换配置 kid + JWKS 热替换
报文大小 3 个头约 108 B Bearer 200–800 B
跨语言生态 自写 SDK jwt.io 完整生态
典型场景 内网 S2S、IoT 边缘、窄带场景 BFF、移动端、三方开放 API

场景化选型建议

  • 单体或少量微服务、全部 Go 语言 —— 选 Ed25519 自定义签名,依赖少、带宽省。
  • 面向前端 / 移动 / 第三方伙伴 —— 选 JWT,标准化、省对接成本。
  • 需要抗重放 / 加权限 / 单点登录 —— 直接用 JWT,免造 jti / exp 机制。
  • 极端窄带(LoRa、NB-IoT)或硬件受限 —— 继续用纯签名方案,甚至可再精简。
  • 高安全场景 —— Ed25519 做“消息完整性”,外层叠 mTLS/JWT 做“身份 + 会话”,双保险。

结语

两条技术路线各有侧重:

  • 自定义 Ed25519 →「最小依赖 + 最小报文 + 最小功能」
  • JWT →「功能丰富 + 生态成熟 + 易扩展」

只要弄清业务边界、带宽约束、团队规模与未来演进,选型并不困难。希望本文能帮助 Go 开发者在“接口签名 / 认证”这道必考题上做出合适选择。Happy Coding!

作者

zion h4

发布于

2025-06-18

更新于

2025-06-18

许可协议

评论