HMAC: 해시 기반 메시지 인증 코드의 원리와 활용
HMAC의 내부 구조(이중 해시), 길이 확장 공격 방어 원리, API 서명·JWT·웹훅 검증·TOTP 등 실무 사용 사례, 타이밍 공격 방어법을 다룹니다.
지난 글에서 Diffie-Hellman과 ECDH로 두 당사자가 공유 비밀을 만드는 방법을 살펴봤다. 그렇다면 공유한 키로 메시지가 변조되지 않았음을 어떻게 증명할까? 이번 글에서는 무결성과 인증을 동시에 제공하는 HMAC(Hash-based Message Authentication Code)을 다룬다.
MAC이란 무엇인가?
단순 해시(SHA-256 등)는 무결성을 검증한다 — 데이터가 변조되지 않았는지 확인할 수 있다. 하지만 인증은 제공하지 않는다. 공격자가 메시지와 해시를 모두 교체하면 수신자는 알아챌 수 없다.
MAC(Message Authentication Code)은 비밀 키를 사용해 무결성과 인증을 동시에 제공한다. 키 없이는 유효한 MAC을 만들 수 없다.
import hashlib
# ❌ 단순 해시: 인증 없음
digest = hashlib.sha256(message).hexdigest()
# 공격자도 같은 계산 가능 — 위조 탐지 불가
# ✅ HMAC: 키 있어야 생성 가능
import hmac
mac = hmac.new(secret_key, message, hashlib.sha256).hexdigest()
# 키 없으면 유효한 MAC 생성 불가 → 위조 탐지 가능
HMAC의 내부 구조
HMAC은 단순히 HASH(key + message)가 아니다. 이 naive한 방식은 길이 확장 공격(Length Extension Attack)에 취약하기 때문이다.
HMAC은 이중 해시 구조로 길이 확장 공격을 원천 차단한다:
# HMAC 수동 구현 (이해용)
def hmac_sha256(key: bytes, message: bytes) -> bytes:
block_size = 64 # SHA-256 블록 크기
# 키를 블록 크기로 맞춤
if len(key) > block_size:
key = hashlib.sha256(key).digest()
key = key.ljust(block_size, b'\x00')
# ipad, opad 상수
ipad = bytes([0x36] * block_size)
opad = bytes([0x5c] * block_size)
# XOR 연산
k_ipad = bytes(a ^ b for a, b in zip(key, ipad))
k_opad = bytes(a ^ b for a, b in zip(key, opad))
# 이중 해시
inner = hashlib.sha256(k_ipad + message).digest()
outer = hashlib.sha256(k_opad + inner).digest()
return outer
# 표준 라이브러리 사용 (실무에서는 항상 이 방식)
import hmac, hashlib
result = hmac.new(key, message, hashlib.sha256).digest()
왜 이중 해시인가? — 길이 확장 공격
SHA-256, MD5 등 Merkle-Damgård 구조 해시 함수는 HASH(K ∥ M) 형태에서 길이 확장 공격에 취약하다.
공격자가 알고 있는 것:
- H(K ∥ M) 값
- M의 길이 (K 길이 모름)
공격자가 할 수 있는 것:
- K 없이도 H(K ∥ M ∥ padding ∥ M') 계산 가능
- 즉, 원본 M 뒤에 M' 추가한 유효한 MAC 위조 가능!
HMAC의 외부 해시가 이를 막는다. H(K_opad ∥ inner)에서 inner 뒤에 공격자가 데이터를 추가해도 K_opad와 XOR된 키 없이는 외부 해시를 다시 계산할 수 없다.
실무 활용 사례
1. Webhook 서명 검증
GitHub, Stripe, Slack 등 주요 서비스가 채택하는 방식이다.
import hmac
import hashlib
def verify_github_webhook(payload: bytes, signature_header: str, secret: str) -> bool:
"""GitHub Webhook 서명 검증"""
if not signature_header.startswith('sha256='):
return False
received = signature_header[7:] # 'sha256=' 제거
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
# 반드시 compare_digest 사용 — 타이밍 공격 방어
return hmac.compare_digest(expected, received)
# Flask 예시
from flask import request, abort
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Hub-Signature-256', '')
if not verify_github_webhook(request.data, signature, WEBHOOK_SECRET):
abort(403)
process_payload(request.json)
return 'OK'
2. JWT HS256 서명
import base64
import json
import hmac
import hashlib
def jwt_hs256_sign(payload: dict, secret: str) -> str:
header = {"alg": "HS256", "typ": "JWT"}
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
header_b64 = b64url(json.dumps(header, separators=(',', ':')).encode())
payload_b64 = b64url(json.dumps(payload, separators=(',', ':')).encode())
signing_input = f"{header_b64}.{payload_b64}".encode()
signature = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
return f"{header_b64}.{payload_b64}.{b64url(signature)}"
# 검증
def jwt_hs256_verify(token: str, secret: str) -> dict:
header_b64, payload_b64, sig_b64 = token.split('.')
signing_input = f"{header_b64}.{payload_b64}".encode()
expected = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
received = base64.urlsafe_b64decode(sig_b64 + '==')
if not hmac.compare_digest(expected, received):
raise ValueError("Invalid signature")
return json.loads(base64.urlsafe_b64decode(payload_b64 + '=='))
3. TOTP (Time-based One-Time Password)
Google Authenticator 등 OTP 앱의 기반 기술이다.
import hmac
import hashlib
import time
import struct
def generate_totp(secret: bytes, digits: int = 6, interval: int = 30) -> str:
"""RFC 6238 TOTP 구현"""
# 현재 타임스텝 (30초 단위)
counter = int(time.time()) // interval
# HMAC-SHA1(시크릿, 카운터)
counter_bytes = struct.pack('>Q', counter)
mac = hmac.new(secret, counter_bytes, hashlib.sha1).digest()
# 동적 잘라내기
offset = mac[-1] & 0x0F
code = struct.unpack('>I', mac[offset:offset + 4])[0]
code &= 0x7FFFFFFF # 최상위 비트 제거
return str(code % (10 ** digits)).zfill(digits)
타이밍 공격과 compare_digest
HMAC 검증에서 == 비교를 사용하면 타이밍 공격(Timing Attack)에 취약하다.
# ❌ 위험: 문자열 비교는 첫 불일치 위치에서 즉시 반환
if received_mac == expected_mac: # 시간 차이로 일치 길이 유추 가능
# ✅ 안전: 전체를 동일 시간에 비교
import hmac
if hmac.compare_digest(received_mac, expected_mac): # 항상 동일 시간
공격자는 응답 시간 차이를 수천 번 측정해 예상 MAC과 수신 MAC이 몇 바이트나 일치하는지 알아낼 수 있다. compare_digest는 항상 동일한 시간이 걸리도록 XOR 비교를 수행해 이를 방어한다.
HMAC 알고리즘 선택
import hmac
import hashlib
# 권장 순서
hmac.new(key, msg, hashlib.sha256) # ✅ 범용 — HMAC-SHA256
hmac.new(key, msg, hashlib.sha384) # ✅ 고보안 요구시
hmac.new(key, msg, hashlib.sha512) # ✅ 고보안 요구시
# 레거시 — 무결성 용도라면 피할 것
hmac.new(key, msg, hashlib.sha1) # ⚠ TOTP는 RFC 6238로 SHA1 명시
hmac.new(key, msg, hashlib.md5) # ❌ 사용 금지
HMAC-SHA256이 가장 널리 사용된다. SHA-1은 TOTP 같이 표준에 명시된 경우에만 사용한다. SHA-256 이상이면 현재로서 충분히 안전하다.
지난 글: 키 교환 프로토콜: Diffie-Hellman과 ECDH
다음 글: 키 유도 함수: PBKDF2·bcrypt·Argon2·scrypt 비교
읽어주셔서 감사합니다. 😊