[파이썬 100강] 58강. hmac으로 메시지 위변조 검증 자동화하기

[파이썬 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)입니다.
  • 검증 실패는 경고가 아니라 차단 이벤트로 처리해야 시스템이 안전해집니다.

연습문제

  1. verify_webhook(secret, raw_body, received_signature) 함수를 작성하세요. 서명 형식이 sha256=<hex>가 아니면 바로 False를 반환하도록 만드세요.
  2. 타임스탬프(X-Timestamp)와 본문을 결합해 서명하는 규칙을 만들고, 허용 시간 300초를 넘는 요청을 거부하는 검증 함수를 작성하세요.
  3. 키 로테이션 상황을 가정해 current_key, previous_key 두 개로 검증하는 로직을 구현하세요. 단, previous_key는 24시간 유예 기간 이후 무효화되도록 설계해 보세요.

이전 강의 정답

  1. 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
  1. 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)
  1. 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']

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: hmac, hashlib, time, json
  • 재현 절차:
    1. 기본 사용 예제 1~2로 서명 생성/검증 루틴을 먼저 고정합니다.
    2. 예제 3으로 raw body 기준 검증을 확인하고, JSON 파싱 전후 차이를 실험합니다.
    3. 예제 4를 적용해 타임스탬프 유효성 검사를 추가하고 재전송 허용 범위를 점검합니다.
    4. 실수 섹션 1~4를 순서대로 재현해 실패 원인과 방어 포인트를 확인합니다.
  • 검증 포인트:
    • 동일 키+동일 바이트 입력이면 항상 동일 서명
    • 잘못된 키/본문/타임스탬프에서 검증 실패
    • compare_digest 사용 여부와 raw body 유지 여부