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 實際上是如何運作的,從握手到安全資料傳輸。