FlowingDev

HMAC 详解:数字世界的秘密握手

了解 HMAC(基于哈希的消息认证码)如何验证数据的完整性和真实性,确保消息在传输过程中不被篡改。

试用工具: HMAC 生成器

一言以蔽之

HMAC 就是一种加密的“秘密握手”,它使用共享密钥来证明消息是真实可靠的,并且没有被动过手脚。

它解决了什么问题

在互联网早期那个狂野奔放的西部时代,发送消息就像寄明信片。任何截获它的人都可以阅读,甚至可以在把它继续传递之前在上面涂鸦一番。如果你收到一张明信片,上面写着“午夜见面,带上现金”,你怎么能确定这真是你的特工联系人发的,而不是他的死对头“邪恶夏娃”(Evil Eve)发的?你又怎么能确定原文不是“中午见面,友好地吃个午饭”呢?

这就是 真实性(这消息真是你发的吗?)和 完整性(内容被改过吗?)的双重问题。

用一个简单的加密 hash 函数(比如 SHA-256)似乎是解决问题的第一步。你可以对你的消息进行 hash,然后把消息和 hash 值一起发送出去,接收方可以重新对消息进行 hash,看看两者是否匹配。太棒了!这解决了完整性问题。如果消息中有一个字节被更改,hash 值就对不上了。

但这并不能解决真实性问题。邪恶夏娃完全可以修改消息,为她的 新 消息计算一个 新 的 hash,然后把这两样东西一起发过去。接收方会看到 hash 值与消息匹配,但他们无法知道整个包裹都是伪造的。

就在这时,HMAC(基于哈希的消息认证码)闪亮登场了。通过在 hash 过程中引入一个 共享密钥,HMAC 创建了一个只有拥有该密钥的人才能生成的签名。这就好比一个普通的火漆印(谁都能做一个)和一个用独一无二的权戒盖的火漆印(只有国王才有)之间的区别。HMAC 同时给了我们完整性 和 真实性。

它的底层工作原理

HMAC 不是一种新的 hash 函数,它更像一个 菜谱,用一种巧妙而特殊的方式来使用现有的 hash 函数(比如 SHA-256)。官方规范是 RFC 2104,但咱们还是用大白话把它掰扯清楚。

原料

要炮制一份 HMAC,你需要三样东西:

  1. 消息 (Message): 你想要保护的数据。可以是一个 webhook 的 JSON payload、一串 URL 参数,或者任何二进制数据块。
  2. 密钥 (Secret Key): 一段只有发送方和接收方知道的字节串。这是个神奇的配料。一旦泄露,整个系统就完蛋了。
  3. Hash 函数: 一个标准算法,比如 SHA-1、SHA-256 或 SHA-512。你选用的 hash 函数决定了最终 HMAC 签名的长度(例如,HMAC-SHA256 会生成一个 256 位的签名)。

制作步骤(HMAC 算法)

你可能会想,直接 hash(key + message) 不就完了吗?看起来是简单,但这种结构容易受到一些被称为“长度扩展攻击”的加密诡计的攻击。官方的 HMAC 构造要复杂一些,专门为了防止这些攻击。它使用了一个双重 hash 的过程。

下面是步骤的简化版:

  1. 准备密钥: Hash 函数是按固定大小的数据块(比如 SHA-256 是 64 字节)进行操作的。所以需要对密钥进行处理,让它适应这个块大小。

    • 如果密钥比块大小长,就对密钥本身进行一次 hash,然后用 hash 结果作为新密钥。
    • 如果密钥比块大小短,就用零字节填充,直到达到块大小。
  2. 创建内部和外部密钥: 从这个准备好的密钥中,我们派生出两个独立的密钥。

    • ipad (inner pad):一个常量字节(0x36),重复填充直到达到块大小。
    • opad (outer pad):另一个不同的常量字节(0x5C),重复填充直到达到块大小。

    我们通过将准备好的密钥与 ipad 进行异或(XOR)运算,来创建一个 inner_padded_key。我们通过将准备好的密钥与 opad 进行异或运算,来创建一个 outer_padded_key。

  3. 执行双重 Hash: 现在是重头戏。

    • 内部 Hash: 将 inner_padded_key 与原始消息拼接起来,然后用 hash 函数处理一遍。
    • 外部 Hash: 将 outer_padded_key 与 内部 hash 的结果拼接起来,再用 hash 函数处理一遍。

这个外部 hash 的最终结果就是你的 HMAC 签名!

用伪代码表示,它看起来像这样:

function hmac(key, message, hash_function, block_size) {

  // 1. Prepare the key
  if (key.length > block_size) {
    key = hash_function(key);
  }
  if (key.length < block_size) {
    key = pad_with_zeros(key, block_size);
  }

  // 2. Create inner and outer padded keys
  o_key_pad = key XOR (0x5C repeated to block_size);
  i_key_pad = key XOR (0x36 repeated to block_size);

  // 3. Perform the double hash
  inner_hash_result = hash_function(i_key_pad + message);
  final_hmac = hash_function(o_key_pad + inner_hash_result);

  return final_hmac;
}

为啥要搞双重 Hash?

这种先内后外的结构就是秘密武器。内部 hash 结合了密钥和消息。外部 hash 随后本质上是用密钥再次对第一次操作的 结果 进行 hash。这“封印”了内部 hash。这使得攻击者在不知道密钥的情况下,从计算上来说根本不可能操纵中间的 hash 结果,从而挫败了长度扩展攻击和其他潜在的加密破解。这是一个经过验证的、健壮的结构,经受住了时间的考验。

真实世界的案例

GitHub Webhook 的守护神

一个创业团队配置了他们的持续集成(CI)服务器,每当有人推送到 main 分支时就自动部署应用到生产环境。触发器是来自 GitHub 的一个 webhook:一个从 GitHub 服务器发送到他们 CI 服务器上某个公开 URL 的 POST 请求。一天晚上,他们团队里爱搞恶作剧的实习生找到了这个公开 URL,用一个简单的 cURL 命令就开始发送假的 webhook payload,触发了几十次毫无用处、还消耗资源的部署。

资深开发 15 分钟就搞定了。她在 GitHub 的 webhook 设置里生成了一个长长的随机“密钥”。她复制了这个密钥,并将其配置为 CI 服务器上的一个环境变量。GitHub 现在会用这个密钥为每个 webhook payload 生成一个 HMAC-SHA256 签名,并通过 X-Hub-Signature-256 header 发送过来。CI 服务器的代码也更新了,用同样的密钥对自己收到的原始请求体(raw request body)执行相同的 HMAC 计算。如果它计算出的签名与 header 中的签名匹配,请求就会被处理。如果不匹配,就直接返回 403 Forbidden 拒绝掉。恶作剧立刻停止了。

经验之谈: 一定要用 HMAC 签名验证来保护你的 webhook。在认证之前,不要相信任何传入的请求。

保护 API 的 Cookie 罐

一个开发者正在开发一个服务,该服务使用简单的签名 cookie 进行身份验证。当用户登录时,服务器会签发一个包含他们 user_id 和 expiry 时间戳的 cookie。为了防止用户编辑自己的 cookie 来冒充其他用户(例如,把 user_id=123 改成 user_id=1),这位开发者加入了一个 HMAC 签名。

cookie 的 payload 大概长这样:user_id=123&expiry=1678886400。服务器会用一个存储在服务器端的密钥对这个确切的字符串进行签名。最终发送到浏览器的 cookie 是 data="user_id=123&expiry=1678886400"&signature="sha1=2a8b..."。

当用户发起后续请求时,浏览器会把 cookie 发回来。服务器会取出 data 部分,用它的密钥重新计算 HMAC 签名,并与 cookie 的 signature 部分进行比较。如果匹配,服务器就知道用户 ID 和过期日期是合法的,没有被篡改过。

经验之谈: HMAC 是创建防篡改、“无状态” token 或 cookie 的绝佳方式,它构成了许多认证系统的基础,包括 JSON Web Token (JWT)。

那笔没有被劫持的银行转账

一个电子商务平台与支付处理器的 API 集成,以便向其供应商发起支付。API 调用是一个简单的 JSON 消息:{"vendor_id": "ven_abc", "amount": 500.00, "currency": "USD"}。该平台担心会遭到中间人攻击(Man-in-the-Middle, MITM)。即使是用了 HTTPS 加密流量,一个技术高超的攻击者也可能(在某些理论场景下,比如证书颁发机构被攻破时)拦截并修改请求。他们可能会把 amount 改成 50000.00,或者把 vendor_id 改成他们自己的。

该支付处理器的 API 要求每个请求都必须用 HMAC-SHA512 签名。该平台会将 JSON payload 序列化为一个规范的字符串,用他们的私有 API 密钥计算签名,并将其放在 Authorization header 中发送。支付处理器的服务器会执行完全相同的步骤。如果他们计算出的签名与 header 中发送的签名匹配,他们就能百分百确定两件事:这个请求来自合法的平台(真实性),并且 vendor_id 和 amount 在传输过程中没有被修改过(完整性)。

经验之谈: 对于高风险操作,HMAC 提供了一个关键的安全层,以确保消息的发送者和内容都符合你的预期。

常见错误和陷阱

  • 泄露密钥。 密钥就是一切。如果你在客户端的 JavaScript 中暴露了它,或者把它提交到了公共的 Git 仓库,或者用明文记录在日志里,你的安全性就荡然无存了。请像对待密码一样对待它。
  • 使用非恒定时间比较函数。 当你检查用户提供的签名是否与你计算的签名匹配时,使用标准的字符串比较(如 if (a === b))可能是一个安全漏洞。这种比较一旦发现不匹配的字符就会立即返回 false。这会造成微小的时间差,攻击者可以利用这个时间差来一次猜测一个字符,最终猜出整个签名。这就是“时间侧信道攻击”(timing attack)。一定要使用加密库里专门的、“恒定时间”的比较函数,这种函数无论在哪个位置发现不匹配,花费的时间都完全相同。
  • 签错了数据。 发送方和接收方必须对 完全相同的字节序列 进行 HMAC 计算。一个常见的 bug 是一方对格式化后的 JSON 对象进行签名,而另一方则对压缩后的一行版本进行签名。或者一方包含了末尾的换行符而另一方没有。你必须商定一个规范的消息格式并严格遵守。
  • 忘记了重放攻击(replay attacks)。 HMAC 本身并不能阻止攻击者捕获一个有效的、已签名的消息,然后一遍又一遍地重新发送它。如果那个消息是“付给 Bob 10 美元”,你肯定不希望攻击者能触发这笔支付 1000 次。为了防止这种情况,你应该在 被签名的数据内部 包含一个每次请求都会变化的值——比如一个时间戳或者一个“nonce”(一次性数字)。然后服务器就可以检查时间戳来拒绝旧的请求,或者维护一个已使用的 nonce 列表来拒绝重复的请求。

为什么你应该关注它

每当你处理需要被信任的通信时,都应该想到 HMAC。它的作用不是为了保密数据(那是加密的工作),而是为了确保数据的合法性。

  • 构建或使用 API? 特别是对于 webhook(来自 Stripe、GitHub、Twilio 等服务),HMAC 是验证请求真实性的行业标准。
  • 处理身份认证? 许多基于 token 的系统,最著名的就是 JWT,都使用 HMAC(例如 'HS256' 算法)来对 token 的 payload 进行签名,以防止用户修改自己的权限。
  • 需要验证数据完整性? 如果你需要在不受信任的环境(比如用户浏览器的 cookie)中传递数据,并需要确保它返回时没有被修改,HMAC 就是你的不二之选。

它是 Web 开发者安全工具箱里的一个基本元件。理解它的工作原理会让你成为一个更优秀、更有安全意识的工程师。

深入了解

理论搞定,动手试试吧——100% 在你的浏览器中运行。

试用工具: HMAC 生成器