[파이썬 100강] 58강. hmac으로 메시지 위변조 검증 자동화하기
파일 해시만으로는 “데이터가 바뀌었는지”는 알 수 있어도, 누가 만든 데이터인지까지는 보장하지 못합니다. API 웹훅, 내부 서비스 간 이벤트, 배치 결과 파일 전달처럼 "신뢰된 발신자"를 확인해야 하는 상황에서는 hmac이 기본 안전장치가 됩니다. 이번 강의는 이론을 길게 끌지 않고, 바로 검증 코드를 만들고 운영에 붙이는 데 집중합니다.
핵심 개념
- HMAC(Hash-based Message Authentication Code)은 비밀 키 + 메시지를 함께 사용해 서명을 만드는 방식이며, 수신자는 같은 키로 재계산해 진위 여부를 확인합니다.
- 일반
hashlib.sha256(message)는 누구나 계산할 수 있지만,hmac는 비밀 키를 모르면 같은 결과를 만들 수 없어 발신자 검증에 유리합니다. - 검증 시에는 문자열
==비교 대신hmac.compare_digest()를 사용해야 타이밍 공격 위험을 줄일 수 있습니다.
실무에서 초보자가 가장 자주 헷갈리는 지점은 “해시랑 HMAC이 뭐가 다르지?”입니다. 해시는 무결성 확인에 강하지만, 키가 없어서 발신자 인증까지는 못 합니다. 반면 HMAC은 같은 메시지라도 키가 다르면 완전히 다른 서명이 나오므로, 키를 공유한 당사자만 올바른 서명을 만들 수 있습니다. 그래서 외부 결제 웹훅, 슬랙/깃허브 웹훅 검증, 내부 큐 메시지 서명 같은 장면에서 HMAC이 표준처럼 쓰입니다. 핵심은 단순히 코드 한 줄이 아니라, "무엇을 서명할지(원문 바이트 그대로)"와 "언제 검증 실패로 즉시 차단할지"를 정책으로 명확히 두는 것입니다.
기본 사용
예제 1) 가장 기본 패턴: 메시지 서명 만들기
>>> import hmac, hashlib
>>> secret = b"my-secret-key"
>>> message = b"order_id=1024&amount=39000"
>>> sig = hmac.new(secret, message, hashlib.sha256).hexdigest()
>>> sig
'bd50d1530f6f39fe9e63573e79d1a4e9be588d022be26033973a4ac664827804'
해설:
hmac.new(key, msg, digestmod)형태를 기억하면 거의 모든 환경에서 응용할 수 있습니다.- 메시지는 반드시 바이트(
bytes)여야 하며, 문자열이면encode()가 필요합니다. - 실무에서는 이 결과를 HTTP 헤더(
X-Signature)로 보내는 패턴이 많습니다.
예제 2) 검증 함수 만들기 + compare_digest 사용
>>> import hmac, hashlib
>>> def sign_message(secret: bytes, message: bytes) -> str:
... return hmac.new(secret, message, hashlib.sha256).hexdigest()
...
>>> def verify_message(secret: bytes, message: bytes, received_sig: str) -> bool:
... expected = sign_message(secret, message)
... return hmac.compare_digest(expected, received_sig)
...
>>> secret = b"my-secret-key"
>>> payload = b'{"event":"invoice.paid","id":7788}'
>>> good_sig = sign_message(secret, payload)
>>> verify_message(secret, payload, good_sig)
True
>>> verify_message(secret, payload, "0" * 64)
False
해설:
compare_digest를 쓰면 비교 과정에서 길이/문자 차이에 따른 시간 편차가 줄어듭니다.- 실패 시 단순
False만 반환하지 말고, 운영 코드에서는 요청 ID와 함께 감사 로그를 남기는 것이 좋습니다. - 검증 실패를 “경고만”으로 두면 결국 우회 경로가 생기므로, 기본 정책은 즉시 차단입니다.
예제 3) JSON 웹훅 검증: 원문 바이트 그대로 서명
>>> import json, hmac, hashlib
>>> secret = b"webhook-secret"
>>> body_bytes = b'{"user":"kim","score":95}'
>>> signature = hmac.new(secret, body_bytes, hashlib.sha256).hexdigest()
>>>
>>> # 서버 수신 측
>>> received_body = body_bytes # 프레임워크가 받은 raw body라고 가정
>>> received_sig = signature
>>> expected = hmac.new(secret, received_body, hashlib.sha256).hexdigest()
>>> hmac.compare_digest(expected, received_sig)
True
해설:
- 핵심은
json.loads()후 재직렬화한 문자열이 아니라 수신한 raw body를 그대로 서명 검증에 사용해야 한다는 점입니다. - 공백, 키 순서, 이스케이프 차이 때문에 논리적으로 같은 JSON도 바이트가 달라질 수 있습니다.
- 웹 프레임워크에서 raw body를 먼저 보관한 뒤 검증하고, 그 다음 파싱하는 순서를 습관화하세요.
예제 4) 타임스탬프를 결합해 재전송(replay) 위험 줄이기
>>> import time, hmac, hashlib
>>> secret = b"replay-guard-key"
>>> ts = str(int(time.time()))
>>> body = b"transfer=5000"
>>> signed_text = ts.encode() + b"." + body
>>> sig = hmac.new(secret, signed_text, hashlib.sha256).hexdigest()
>>>
>>> # 검증 측
>>> now = int(time.time())
>>> abs(now - int(ts)) <= 300
True
>>> expected = hmac.new(secret, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
>>> hmac.compare_digest(expected, sig)
True
해설:
- 서명만 맞고 오래된 요청이면 재전송 공격일 수 있으니, 허용 시간 창(예: 5분) 검증을 추가합니다.
- 타임스탬프와 본문을 어떤 포맷으로 합칠지(예:
{ts}.{body})를 팀 규칙으로 고정해야 합니다.
자주 하는 실수
실수 1) == 비교로 서명 검증하기
>>> import hmac
>>> expected = "abc123"
>>> received = "abc124"
>>> expected == received
False
원인:
- 기능상 동작은 하지만, 민감한 비교에서
==는 타이밍 기반 정보 노출 여지가 있습니다.
해결:
>>> import hmac
>>> hmac.compare_digest("abc123", "abc124")
False
실수 2) 문자열 인코딩을 발신/수신에서 다르게 처리
>>> import hmac, hashlib
>>> secret = b"k"
>>> text = "안녕하세요"
>>> sig_utf8 = hmac.new(secret, text.encode("utf-8"), hashlib.sha256).hexdigest()
>>> sig_cp949 = hmac.new(secret, text.encode("cp949"), hashlib.sha256).hexdigest()
>>> sig_utf8 == sig_cp949
False
원인:
- 같은 문장처럼 보여도 바이트가 다르면 HMAC 결과도 완전히 달라집니다.
해결:
- 프로토콜 문서에 인코딩(
UTF-8)을 고정하고, 검증 전에 인코딩 변환이 일어나지 않도록 raw bytes 기준으로 처리합니다.
실수 3) 파싱 후 재직렬화한 JSON으로 검증
- 증상: 발신자는 정상인데 수신 측에서 간헐적으로 서명 불일치가 발생합니다.
- 원인: 수신 측에서 JSON 파싱 후
json.dumps()로 다시 문자열을 만들어 서명 계산을 했습니다. - 해결: 반드시 요청 본문의 원문(raw body) 바이트를 사용하고, 검증 통과 후에만 파싱합니다.
실수 4) 키 관리 부주의
- 증상: 저장소 유출 또는 로그 수집 시스템에서 비밀 키가 노출됩니다.
- 원인: 키를 하드코딩하거나 디버그 로그로 출력했습니다.
- 해결: 환경변수/시크릿 매니저로 주입하고, 로그에는 키를 절대 남기지 않습니다. 키 로테이션 주기도 운영 정책으로 정의합니다.
실무 패턴
- 입력 검증 규칙: 헤더 서명 형식(
sha256=<hex>)을 정규식으로 먼저 확인하고, 길이/문자셋이 맞지 않으면 즉시 400 처리합니다. - 로그/예외 처리 규칙: 검증 실패 로그에는
request_id,client_id,timestamp만 남기고 본문 전체/서명 전체는 마스킹합니다. - 재사용 함수/구조화 팁:
build_signing_payload(ts, body),sign_payload(secret, payload),verify_signature(...)3단으로 나누면 테스트와 교체가 쉽습니다. - 성능/메모리 체크 포인트: 일반 웹훅은 바디가 작아 부담이 적지만, 대용량 페이로드는 스트리밍 기반 HMAC 계산(청크 update)으로 전환합니다.
운영 관점에서 중요한 건 “검증 실패를 어떻게 처리할지”입니다. 첫째, 실패 즉시 비즈니스 로직으로 들어가지 않도록 라우터/미들웨어 단계에서 차단합니다. 둘째, 실패율이 일정 임계치를 넘으면 알림을 보내 공격/오동작을 조기 탐지합니다. 셋째, 키 로테이션 시 구버전 키를 짧은 유예 기간 동안만 병행 허용하고, 로그로 어떤 키 버전이 사용됐는지 식별 가능하게 만듭니다. 넷째, 재전송 방지를 위해 타임스탬프와 nonce(일회성 값)를 함께 검증하면 안정성이 크게 올라갑니다. 결국 HMAC은 “암호학 이론”보다 “운영 규칙”이 품질을 좌우합니다.
오늘의 결론
한 줄 요약: HMAC은 데이터 무결성 + 발신자 검증을 동시에 잡는 실전 기본기이며, raw body 검증과 compare_digest 사용이 핵심입니다.
기억할 것:
- 해시는 공개 계산, HMAC은 비밀 키 기반 검증이라는 차이를 명확히 구분하세요.
- 서명 대상은 파싱 결과가 아니라 원문 바이트(raw body)입니다.
- 검증 실패는 경고가 아니라 차단 이벤트로 처리해야 시스템이 안전해집니다.
연습문제
verify_webhook(secret, raw_body, received_signature)함수를 작성하세요. 서명 형식이sha256=<hex>가 아니면 바로False를 반환하도록 만드세요.- 타임스탬프(
X-Timestamp)와 본문을 결합해 서명하는 규칙을 만들고, 허용 시간 300초를 넘는 요청을 거부하는 검증 함수를 작성하세요. - 키 로테이션 상황을 가정해
current_key,previous_key두 개로 검증하는 로직을 구현하세요. 단,previous_key는 24시간 유예 기간 이후 무효화되도록 설계해 보세요.
이전 강의 정답
compute_sha256(path)함수 작성
>>> from pathlib import Path
>>> import hashlib
>>> def compute_sha256(path: Path) -> str:
... return hashlib.sha256(path.read_bytes()).hexdigest()
...
>>> p = Path("a.txt"); _ = p.write_text("A", encoding="utf-8")
>>> q = Path("b.txt"); _ = q.write_text("B", encoding="utf-8")
>>> r = Path("c.txt"); _ = r.write_text("C", encoding="utf-8")
>>> len(compute_sha256(p))
64
is_same_file(a, b)함수 작성
>>> from pathlib import Path
>>> import hashlib
>>> def sha256_file(path: Path) -> str:
... return hashlib.sha256(path.read_bytes()).hexdigest()
...
>>> def is_same_file(a: Path, b: Path) -> bool:
... if a.stat().st_size != b.stat().st_size:
... return False
... return sha256_file(a) == sha256_file(b)
...
>>> x = Path("x.txt"); _ = x.write_text("hello", encoding="utf-8")
>>> y = Path("y.txt"); _ = y.write_text("hello", encoding="utf-8")
>>> z = Path("z.txt"); _ = z.write_text("world", encoding="utf-8")
>>> is_same_file(x, y), is_same_file(x, z)
(True, False)
checksums.txt일괄 검증
>>> from pathlib import Path
>>> import hashlib
>>> def sha256_file(path: Path) -> str:
... return hashlib.sha256(path.read_bytes()).hexdigest()
...
>>> Path("d1.txt").write_text("apple", encoding="utf-8")
5
>>> Path("d2.txt").write_text("banana", encoding="utf-8")
6
>>> c1 = sha256_file(Path("d1.txt"))
>>> c2 = sha256_file(Path("d2.txt"))
>>> _ = Path("checksums.txt").write_text(f"{c1} d1.txt\n{'0'*64} d2.txt\n", encoding="utf-8")
>>> failed = []
>>> for line in Path("checksums.txt").read_text(encoding="utf-8").splitlines():
... expected, name = line.split()
... actual = sha256_file(Path(name))
... if actual != expected:
... failed.append(name)
...
>>> failed
['d2.txt']
실습 환경/재현 정보
- 실행 환경:
condaenvpython100(Python 3.11.14) - 가정한 OS: macOS/Linux 공통
- 사용 모듈:
hmac,hashlib,time,json - 재현 절차:
- 기본 사용 예제 1~2로 서명 생성/검증 루틴을 먼저 고정합니다.
- 예제 3으로 raw body 기준 검증을 확인하고, JSON 파싱 전후 차이를 실험합니다.
- 예제 4를 적용해 타임스탬프 유효성 검사를 추가하고 재전송 허용 범위를 점검합니다.
- 실수 섹션 1~4를 순서대로 재현해 실패 원인과 방어 포인트를 확인합니다.
- 검증 포인트:
- 동일 키+동일 바이트 입력이면 항상 동일 서명
- 잘못된 키/본문/타임스탬프에서 검증 실패
compare_digest사용 여부와 raw body 유지 여부