找回密码
 立即注册
首页 业界区 科技 Node和Go使用HMAC互相签名和验签如何保证同一个请求签名 ...

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

裸历 2025-8-5 13:05:45
如何保证使用 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 函数在处理 map[string]interface{} 类型时, 默认就会按键的字母顺序进行排序 ,这天然地实现了规范化(请看参考示例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
  1. // 规范化 JSON, 确保 key 的顺序一致
  2. // 注意: 该函数不处理特殊对象类型(如 Date/Map/Set 等)
  3. const canonicalizeJSON = (obj: any): any => {
  4.     // 处理数组
  5.     if (Array.isArray(obj)) {
  6.         return obj.map(canonicalizeJSON);
  7.     }
  8.     // 处理非空对象(排除数组、null、基础类型)
  9.     if (typeof obj === "object" && obj !== null) {
  10.         const sorted: { [key: string]: any } = {};
  11.         // sort 会按键名字母顺序排序
  12.         Object.keys(obj).sort().forEach(key => {
  13.             sorted[key] = canonicalizeJSON(obj[key]);
  14.         });
  15.         return sorted;
  16.     }
  17.     return obj;
  18. };
复制代码
参考示例2
  1. // 准备请求体
  2. payload := map[string]interface{}{
  3.     "action": "update",
  4.     "value":  42,
  5.     "target": "system",
  6. }
  7. body, _ := json.Marshal(payload)
  8. // 准备时间戳
  9. timestamp := fmt.Sprintf("%d", time.Now().Unix())
  10. // 创建签名字符串
  11. path := "/api/data"
  12. method := "POST"
  13. stringToSign := method + "\n" + path + "\n" + timestamp + "\n" + string(body)
  14. // 生成签名
  15. h := hmac.New(sha256.New, []byte(secret))
  16. h.Write([]byte(stringToSign))
  17. signature := hex.EncodeToString(h.Sum(nil))
复制代码
参考示例3
  1. // 获取原始body的中间件
  2. const rawBodyParser = bodyParser.json({
  3.     verify: (req, res, buf) => {
  4.         (req as any).rawBody = buf;
  5.     }
  6. });
  7. // some code ...
  8. // 获取原始请求体
  9. const rawBody = (req as any).rawBody || Buffer.from('');
  10. // 构建签名字符串
  11. const path = req.path;
  12. const method = req.method.toUpperCase();
  13. const stringToSign = `${method}\n${path}\n${timestamp}\n${rawBody.toString('utf8')}`;
复制代码
参考示例4
  1. // 获取原始请求体
  2. rawBody, err := io.ReadAll(c.Request.Body)
  3. if err != nil {
  4.     c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
  5.     return
  6. }
  7. // 恢复Body供后续使用
  8. c.Request.Body = io.NopCloser(bytes.NewBuffer(rawBody))
  9. // 构建签名字符串
  10. path := c.Request.URL.Path
  11. method := c.Request.Method
  12. stringToSign := method + "\n" + path + "\n" + timestamp + "\n" + string(rawBody)
复制代码
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册