Luke a Pro

Luke Sun

Developer & Marketer

🇺🇦
EN||

HMAC 和数据完整性:用共享密钥检测篡改

| , 13 minutes reading.

1. 为什么要关心这个问题?

你正在构建一个 API。客户端发送这样的请求:

{"action": "transfer", "amount": 1000, "to": "attacker"}

你怎么知道这个请求在传输过程中没有被修改?你怎么知道它来自授权的客户端?

如果你已经和客户端共享了一个密钥(比如 API 密钥),你可以使用消息认证码(MAC)来验证真实性和完整性——比数字签名更快。

2. 定义

消息认证码 (MAC) 是一小段用于认证消息并验证其完整性的信息。

HMAC(基于哈希的 MAC) 使用密码学哈希函数和密钥来构造 MAC。

MAC 的特性:
- 任何知道密钥的人都可以生成
- 任何知道密钥的人都可以验证
- 没有密钥,无法伪造

数字签名 vs MAC:
- 签名:只有签名者能创建,任何人都能验证
- MAC:任何知道密钥的人都能创建和验证
- 签名:不可否认性
- MAC:没有不可否认性(双方都能创建)

3. 为什么不能只用哈希?

朴素方法(有漏洞)

# 错误:简单哈希不是认证
import hashlib

def naive_integrity(message):
    return hashlib.sha256(message).hexdigest()

# 攻击者可以计算任何消息的哈希!
# 这提供不了任何认证

问题所在

单独的哈希证明:
✗ 谁创建了消息——什么都没有
✗ 授权——什么都没有

因为:
- 哈希函数是公开的
- 任何人都可以计算 SHA256(任何消息)
- 攻击者可以伪造:message' + SHA256(message')

为什么 HMAC 有效

HMAC 包含密钥:
HMAC(key, message) = hash(key || hash(key || message))

只有知道密钥的人才能:
- 计算有效的 MAC
- 验证 MAC

没有密钥,攻击者无法:
- 为修改过的消息计算 MAC
- 找到具有相同 MAC 的不同消息

4. HMAC 构造

HMAC 公式

HMAC(K, m) = H((K' ⊕ opad) || H((K' ⊕ ipad) || m))

其中:
K  = 密钥
K' = 填充到块大小的密钥
H  = 哈希函数(SHA-256 等)
⊕  = 异或
opad = 外层填充(0x5c 重复)
ipad = 内层填充(0x36 重复)
m  = 消息

为什么是这种结构?

简单方法有缺陷:

H(key || message):     长度扩展攻击
H(message || key):     碰撞问题
H(key || message || key):仍然有漏洞

HMAC 的嵌套结构:
- 防止长度扩展
- 有安全性证明
- RFC 2104(1997)以来的标准

5. 在 Python 中使用 HMAC

基本 HMAC

import hmac
import hashlib

key = b"super-secret-api-key"
message = b"action=transfer&amount=1000&to=alice"

# 计算 HMAC
mac = hmac.new(key, message, hashlib.sha256).hexdigest()
print(f"HMAC: {mac}")

# 验证 HMAC(时序安全比较)
def verify_hmac(key, message, received_mac):
    expected_mac = hmac.new(key, message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected_mac, received_mac)

# 使用
is_valid = verify_hmac(key, message, mac)
print(f"有效: {is_valid}")

常见错误:时序攻击

# 错误:容易受到时序攻击
def insecure_verify(expected, received):
    return expected == received  # 泄露长度信息!

# 正确:常数时间比较
import hmac
def secure_verify(expected, received):
    return hmac.compare_digest(expected, received)

# 为什么时序很重要:
# == 运算符在第一个不匹配时提前返回
# 攻击者可以通过测量时间逐字节猜测 MAC
# compare_digest 总是花费相同时间

6. 实际应用

API 请求签名

import hmac
import hashlib
import time
import base64

class APIClient:
    def __init__(self, api_key: str, api_secret: str):
        self.api_key = api_key
        self.api_secret = api_secret.encode()

    def sign_request(self, method: str, path: str, body: str = "") -> dict:
        timestamp = str(int(time.time()))

        # 创建待签名字符串
        string_to_sign = f"{method}\n{path}\n{timestamp}\n{body}"

        # 计算 HMAC
        signature = hmac.new(
            self.api_secret,
            string_to_sign.encode(),
            hashlib.sha256
        ).hexdigest()

        return {
            "X-API-Key": self.api_key,
            "X-Timestamp": timestamp,
            "X-Signature": signature
        }

class APIServer:
    def __init__(self, secrets: dict):
        self.secrets = secrets  # api_key -> api_secret

    def verify_request(self, method: str, path: str, body: str,
                       api_key: str, timestamp: str, signature: str) -> bool:
        # 检查时间戳(防止重放攻击)
        if abs(time.time() - int(timestamp)) > 300:  # 5 分钟窗口
            return False

        # 获取此密钥的密钥
        api_secret = self.secrets.get(api_key)
        if not api_secret:
            return False

        # 重新计算签名
        string_to_sign = f"{method}\n{path}\n{timestamp}\n{body}"
        expected = hmac.new(
            api_secret.encode(),
            string_to_sign.encode(),
            hashlib.sha256
        ).hexdigest()

        return hmac.compare_digest(expected, signature)
import hmac
import hashlib
import base64
import json
import time

class SecureCookie:
    def __init__(self, secret_key: bytes):
        self.secret_key = secret_key

    def create(self, data: dict, max_age: int = 3600) -> str:
        """创建签名的 cookie 值"""
        payload = {
            "data": data,
            "exp": int(time.time()) + max_age
        }
        payload_json = json.dumps(payload, sort_keys=True)
        payload_b64 = base64.b64encode(payload_json.encode()).decode()

        # 签名 payload
        signature = hmac.new(
            self.secret_key,
            payload_b64.encode(),
            hashlib.sha256
        ).hexdigest()

        return f"{payload_b64}.{signature}"

    def verify(self, cookie_value: str) -> dict | None:
        """验证并解码签名的 cookie"""
        try:
            payload_b64, signature = cookie_value.rsplit(".", 1)

            # 验证签名
            expected = hmac.new(
                self.secret_key,
                payload_b64.encode(),
                hashlib.sha256
            ).hexdigest()

            if not hmac.compare_digest(expected, signature):
                return None

            # 解码并检查过期
            payload = json.loads(base64.b64decode(payload_b64))

            if time.time() > payload["exp"]:
                return None

            return payload["data"]

        except Exception:
            return None

# 使用
cookie = SecureCookie(b"my-super-secret-key")
value = cookie.create({"user_id": 123, "role": "admin"})
print(f"Cookie: {value}")

data = cookie.verify(value)
print(f"数据: {data}")

Webhook 验证

import hmac
import hashlib

def verify_github_webhook(payload: bytes, signature: str, secret: str) -> bool:
    """验证 GitHub webhook 签名"""
    # GitHub 发送:sha256=<hex_digest>
    if not signature.startswith("sha256="):
        return False

    expected = "sha256=" + hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

def verify_stripe_webhook(payload: bytes, signature: str, secret: str) -> bool:
    """验证 Stripe webhook 签名"""
    # Stripe 格式:t=timestamp,v1=signature
    parts = dict(p.split("=") for p in signature.split(","))

    # Stripe 签名:timestamp.payload
    signed_payload = f"{parts['t']}.{payload.decode()}"

    expected = hmac.new(
        secret.encode(),
        signed_payload.encode(),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, parts["v1"])

7. HMAC vs 其他 MAC

对比

HMAC:
- 基于哈希函数(SHA-256 等)
- 研究充分,保守的选择
- 比某些替代品稍慢

Poly1305:
- 为速度设计
- 与 ChaCha20 一起使用(ChaCha20-Poly1305)
- 每条消息使用一次性密钥

CMAC/OMAC:
- 基于块密码(AES)
- 在某些标准中使用
- 安全性与 HMAC 类似

GMAC:
- GCM 模式的 MAC 部分
- 有 AES-NI 时非常快
- 需要唯一的 nonce

何时使用什么

使用 HMAC-SHA256 当:
- 需要独立的 MAC
- 最大兼容性
- 保守的安全选择

使用 Poly1305 当:
- 使用 ChaCha20 加密
- 需要最大速度
- 作为认证加密的一部分

使用 GCM/GMAC 当:
- 使用 AES 加密
- 需要认证加密
- 有硬件 AES 支持

8. 安全考虑

密钥管理

HMAC 密钥要求:
- 必须保密(显然)
- 应该是随机的,不是从密码派生的
- 最少 128 位,推荐 256 位
- 不同用途使用不同密钥

如果需要密钥派生:
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes

def derive_hmac_key(master_key: bytes, purpose: str) -> bytes:
    return HKDF(
        algorithm=hashes.SHA256(),
        length=32,
        salt=None,
        info=purpose.encode()
    ).derive(master_key)

HMAC 不提供什么

HMAC 不提供:
✗ 机密性(消息是明文)
✗ 不可否认性(双方都能创建 MAC)
✗ 重放保护(需要时间戳/nonce)

要获得机密性 + 完整性:
→ 使用认证加密(AES-GCM、ChaCha20-Poly1305)

要获得不可否认性:
→ 使用数字签名

要获得重放保护:
→ 在消息中包含时间戳或序列号

常见错误

# 错误:使用密码作为密钥
hmac.new(b"password123", message, hashlib.sha256)

# 正确:从密码派生密钥
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
kdf = Scrypt(salt=salt, length=32, n=2**20, r=8, p=1)
key = kdf.derive(b"password123")
hmac.new(key, message, hashlib.sha256)

# 错误:加密和 MAC 使用相同密钥
aes_key = os.urandom(32)
encrypted = aes_encrypt(aes_key, plaintext)
mac = hmac.new(aes_key, encrypted, hashlib.sha256)

# 正确:分开的密钥
aes_key = os.urandom(32)
mac_key = os.urandom(32)
encrypted = aes_encrypt(aes_key, plaintext)
mac = hmac.new(mac_key, encrypted, hashlib.sha256)

# 最佳:使用认证加密
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data)

9. HMAC 在协议中的应用

TLS

TLS 使用 HMAC(或 AEAD)进行记录认证:

TLS 记录:
┌────────────────────────────────────────────┐
│ 内容类型 │ 版本 │ 长度 │ 负载              │
├────────────────────────────────────────────┤
│              加密 + MAC                     │
└────────────────────────────────────────────┘

TLS 1.2:MAC-then-encrypt 或 AEAD
TLS 1.3:仅 AEAD(GCM 或 ChaCha20-Poly1305)

JWT

JWT 结构:
header.payload.signature

对于 HS256(HMAC-SHA256):
signature = HMAC-SHA256(
    secret,
    base64url(header) + "." + base64url(payload)
)

验证:
1. 将 token 分成部分
2. 重新计算 HMAC
3. 比较签名(常数时间!)

AWS Signature V4

AWS 请求签名使用 HMAC 链:

DateKey      = HMAC-SHA256("AWS4" + SecretKey, Date)
RegionKey    = HMAC-SHA256(DateKey, Region)
ServiceKey   = HMAC-SHA256(RegionKey, Service)
SigningKey   = HMAC-SHA256(ServiceKey, "aws4_request")
Signature    = HMAC-SHA256(SigningKey, StringToSign)

10. 完整示例:签名消息

import hmac
import hashlib
import json
import time
import os
import base64

class SignedMessageProtocol:
    """认证消息的完整协议"""

    def __init__(self, shared_secret: bytes):
        self.key = shared_secret

    def create_message(self, payload: dict) -> str:
        """创建认证消息"""
        # 添加元数据
        message = {
            "payload": payload,
            "timestamp": int(time.time()),
            "nonce": base64.b64encode(os.urandom(16)).decode()
        }

        # 序列化
        message_json = json.dumps(message, sort_keys=True)
        message_b64 = base64.b64encode(message_json.encode()).decode()

        # 创建 MAC
        mac = hmac.new(
            self.key,
            message_b64.encode(),
            hashlib.sha256
        ).hexdigest()

        return f"{message_b64}.{mac}"

    def verify_message(self, signed_message: str,
                       max_age: int = 300) -> dict | None:
        """验证并提取消息"""
        try:
            # 分割
            message_b64, received_mac = signed_message.rsplit(".", 1)

            # 验证 MAC
            expected_mac = hmac.new(
                self.key,
                message_b64.encode(),
                hashlib.sha256
            ).hexdigest()

            if not hmac.compare_digest(expected_mac, received_mac):
                return None

            # 解码
            message_json = base64.b64decode(message_b64)
            message = json.loads(message_json)

            # 检查时间戳
            age = time.time() - message["timestamp"]
            if age < 0 or age > max_age:
                return None

            return message["payload"]

        except Exception:
            return None

# 使用
secret = os.urandom(32)
protocol = SignedMessageProtocol(secret)

# 发送方
msg = protocol.create_message({
    "action": "transfer",
    "amount": 100,
    "to": "alice@example.com"
})
print(f"签名消息: {msg[:50]}...")

# 接收方
payload = protocol.verify_message(msg)
if payload:
    print(f"验证的负载: {payload}")
else:
    print("验证失败!")

11. 本章小结

三点要记住:

  1. HMAC 提供认证和完整性。 与普通哈希不同,HMAC 需要知道密钥。没有密钥,攻击者无法伪造有效的 MAC。

  2. 始终使用常数时间比较。 使用 hmac.compare_digest() 防止时序攻击。普通字符串比较通过时序泄露信息。

  3. HMAC 不能替代加密。 它验证完整性但不隐藏内容。要获得机密性 + 完整性,使用认证加密(AES-GCM)。

12. 下一步

我们现在已经介绍了基本的密码学原语:对称加密、非对称加密、数字签名、证书和 MAC。

在下一节中,我们将看到这些部分如何在真实协议中结合在一起:TLS 深入分析——HTTPS 实际上是如何工作的,从握手到安全数据传输。