裸历 发表于 2025-8-5 13:05:45

Node和Go使用HMAC互相签名和验签如何保证同一个请求签名一致

如何保证使用 node 和 go 传递 json 数据给后台后, node 和 go 后台接收到的 json 格式和用户端传递的是一致的, 经过 HMAC 计算后得到的结果也是一致的
注: 这篇文章只讨论签名和验签问题, 对密钥管理,重放攻击等其他安全问题暂时不做讨论
问题根源

JSON 标准并未规定对象中“键” (key) 的顺序。因此,一个像 { "b": 2, "a": 1 } 的对象,在 Node.js 中可能被序列化为 "{"b":2,"a":1}" ,而在 Go 中则可能因为 map 的特性被序列化为 "{"a":1,"b":2}" 。由于 HMAC 是对原始字节流进行计算的,这两个不同的字符串自然会产生两个完全不同的签名,导致校验失败。
为了确保在使用 Go 和 Node.js 进行 HMAC 校验时, 两端对 JSON 数据生成的签名总是一致的, 核心在于解决 JSON 规范化(Canonicalization)的问题, 这能保证同一个数据对象无论在何种环境下, 都能被序列化成完全相同的字符串
解决方案与最佳实践

要解决这个问题, 必须遵循以下两个原则:

[*]客户端(签名方):必须对 JSON 进行规范化处理后再签名 在生成签名字符串之前,客户端必须确保 JSON 对象的键是经过排序的。最通用的做法是 按字母顺序(字典序)对所有键进行递归排序 。

[*]在 Node.js 客户端中,可以通过通过添加 canonicalize 函数实现这一点。该函数会在 JSON.stringify 之前,递归地对 payload 的所有键进行排序(请看参考示例1)。
[*]在 Go 客户端中, Go 的 json.Marshal 函数在处理 mapinterface{} 类型时, 默认就会按键的字母顺序进行排序 ,这天然地实现了规范化(请看参考示例2)。

[*]服务端(验签方):必须使用原始请求体验证签名 服务端在验证签名时, 绝对不能先将接收到的请求体解析成 JSON 对象,然后再重新序列化来计算签名。因为这会再次引入键顺序不确定的问题。正确的做法是直接使用未经任何处理的、最原始的请求体(raw body)字节流。

[*]在 Node.js 服务端中。可以通过 bodyParser 的 verify 选项,在解析前将原始的 Buffer 存放在 req.rawBody 中,并使用这个原始数据来生成服务端签名进行比对(请看参考示例3)。
[*]在 Go 服务端中。可以通过 io.ReadAll(c.Request.Body) 读取了完整的原始请求体,用它来计算签名,然后再将数据写回 c.Request.Body 以便后续业务逻辑可以继续解析使用(请看参考示例4)。

为什么服务端的原始请求体和客户端的经过排序的 json 经过签名后得到的签名是一致的

这是一个非常关键的问题, 以下详细解释了这个流程:

[*]在客户端(例如 Node.js 或 Go 客户端) :

[*]有一个数据对象,比如 payload = { action: 'update', value: 42, target: 'system' } 。
[*]为了保证签名的一致性,首先对这个对象的键进行排序(规范化),使其变成 { action: 'update', target: 'system', value: 42 } 这样的有序结构。
[*]然后,将这个经过排序的对象序列化成一个 JSON 字符串。这个字符串就是 "{"action":"update","target":"system","value":42}" 。
[*]这个 JSON 字符串,我们称之为 bodyString ,它被用作两件事:

[*]a. 构建 stringToSign ( method + "\n" + path + "\n" + timestamp + "\n" + bodyString ),并用它来生成客户端的 HMAC 签名。
[*]b. 作为 HTTP 请求的 请求体 (Request Body) 发送给服务器。


[*]在服务端(例如 Node.js 或 Go 服务端) :

[*]服务器收到了一个 HTTP 请求。
[*]服务器从这个请求中提取出 原始请求体 (raw request body) 。这个原始请求体就是客户端发送的、未经任何改动的、一字不差的字节序列。因此,它就是 "{"action":"update","target":"system","value":42}" 。
[*]服务器使用这个原始请求体去构建它自己的 stringToSign ,并计算服务端的 HMAC 签名。

结论

客户端用于生成签名的那个经过排序的 JSON 字符串,与服务端接收到的原始请求体, 是同一个东西 。客户端把它放进请求体里,服务端再把它从请求体里读出来。
正是因为它们是完全相同的字节序列,所以当使用相同的密钥(secret)、相同的算法(HMAC-SHA256)和相同的其他参数(方法、路径、时间戳)时,两端计算出的签名才会完全一致,从而使验证得以成功。
关键的错误做法(需要避免的) 是在服务端接收到请求后,先将请求体解析成一个 JSON 对象,然后再重新序列化它来计算签名。这个过程会破坏原始的、经过客户端精心排序的字节序列,导致签名验证失败。
参考示例

参考示例1
// 规范化 JSON, 确保 key 的顺序一致
// 注意: 该函数不处理特殊对象类型(如 Date/Map/Set 等)
const canonicalizeJSON = (obj: any): any => {
    // 处理数组
    if (Array.isArray(obj)) {
      return obj.map(canonicalizeJSON);
    }
    // 处理非空对象(排除数组、null、基础类型)
    if (typeof obj === "object" && obj !== null) {
      const sorted: { : any } = {};
      // sort 会按键名字母顺序排序
      Object.keys(obj).sort().forEach(key => {
            sorted = canonicalizeJSON(obj);
      });
      return sorted;
    }
    return obj;
};参考示例2
// 准备请求体
payload := mapinterface{}{
    "action": "update",
    "value":42,
    "target": "system",
}
body, _ := json.Marshal(payload)

// 准备时间戳
timestamp := fmt.Sprintf("%d", time.Now().Unix())

// 创建签名字符串
path := "/api/data"
method := "POST"
stringToSign := method + "\n" + path + "\n" + timestamp + "\n" + string(body)

// 生成签名
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(stringToSign))
signature := hex.EncodeToString(h.Sum(nil))参考示例3
// 获取原始body的中间件
const rawBodyParser = bodyParser.json({
    verify: (req, res, buf) => {
      (req as any).rawBody = buf;
    }
});

// some code ...

// 获取原始请求体
const rawBody = (req as any).rawBody || Buffer.from('');

// 构建签名字符串
const path = req.path;
const method = req.method.toUpperCase();
const stringToSign = `${method}\n${path}\n${timestamp}\n${rawBody.toString('utf8')}`;参考示例4
// 获取原始请求体
rawBody, err := io.ReadAll(c.Request.Body)
if err != nil {
    c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
    return
}

// 恢复Body供后续使用
c.Request.Body = io.NopCloser(bytes.NewBuffer(rawBody))

// 构建签名字符串
path := c.Request.URL.Path
method := c.Request.Method
stringToSign := method + "\n" + path + "\n" + timestamp + "\n" + string(rawBody)
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页: [1]
查看完整版本: Node和Go使用HMAC互相签名和验签如何保证同一个请求签名一致