OTP 6자리 숫자는 HMAC 출력의 어디를 잘라서 나오는가 — HOTP Dynamic Truncation

seonwooj0810·2026년 8월 12일

1. 도입

OAuth 인가 코드 흐름과 PKCE를 정리하면서, 인증 앱이 30초마다 새로 보여주는 여섯 자리 숫자는 한 번도 뜯어본 적이 없다는 걸 깨달았다. "HMAC 결과를 앞에서 몇 바이트 잘라 숫자로 바꾸는 것"이라고 대충 알고 있었는데, RFC 4226을 읽어보니 자르는 위치 자체가 매번 달라지는 절차였다. 이 글은 HOTP/TOTP가 20바이트 HMAC 출력에서 어떻게 6자리 숫자를 뽑아내는지, 그리고 왜 굳이 자르는 위치를 고정하지 않았는지를 따라간다.

2. 핵심 개념

HOTP는 RFC 4226이 정의한 HOTP(K, C) = Truncate(HMAC-SHA-1(K, C)) 한 줄로 요약된다. K는 등록 시 QR 코드로 전달되는 공유 비밀키, C는 8바이트 big-endian 카운터다. HMAC-SHA-1(RFC 2104)이 20바이트(160비트) 해시를 뱉으면, Truncate 함수가 그걸 6자리 십진수로 압축한다. TOTP(RFC 6238)는 이 카운터 C를 "현재 Unix 시각을 30초 단위로 나눈 정수" T = floor((now - T0) / 30)으로 바꿔 끼운 것뿐이다 — 알고리즘 자체는 그대로고, 카운터의 출처만 이벤트에서 시간으로 바뀐다.

같은 HMAC이지만 용도는 다르다. TLS 1.3 키 스케줄(https://velog.io/@jungseonw00/tls-1-3-key-schedule)에서는 HKDF-Extract/Expand로 HMAC을 반복 적용해 세션 키를 파생했다. 여기서는 키를 만드는 게 아니라, HMAC 출력 자체를 사람이 입력할 수 있는 6자리 숫자로 압축하는 데 쓴다.

3. 내부 동작 — Dynamic Truncation

Truncate 함수(§5.3)가 하는 일은 정확히 세 단계다.

  1. 오프셋 결정: 20바이트 HMAC 출력 중 마지막 바이트(인덱스 19)의 하위 4비트를 오프셋으로 쓴다(offset = hash[19] & 0x0F). 4비트라 값은 0~15 사이고, 20바이트에서 4바이트를 뽑아야 하니 범위가 정확히 맞아떨어진다.
  2. 4바이트 추출: offset부터 4바이트를 big-endian 정수로 읽는다.
  3. 31비트 마스킹: 그 4바이트의 최상위 비트를 0x7f로 강제로 0으로 만든다. 4바이트를 그대로 정수로 읽으면 Java의 int나 C의 signed int처럼 최상위 비트를 부호 비트로 해석하는 언어에서 음수가 나올 수 있어서다. RFC 표현으로는 "masking out the signed bit removes all ambiguity" — 언어·아키텍처와 무관하게 모든 구현이 같은 정수를 얻게 하려는 조치다.

마지막으로 이 31비트 정수를 10^Digit으로 나눈 나머지를 취하면(6자리면 mod 1,000,000) 000000~999999 범위의 6자리 숫자가 나온다.

HMAC-SHA1(K, C) → 20바이트
                     │
        offset = byte[19] & 0x0F        (0~15)
                     │
     P = byte[offset..offset+3]         (4바이트)
                     │
     Snum = P & 0x7FFFFFFF               (31비트 양수)
                     │
        HOTP = Snum mod 10^Digit

오프셋을 고정하지 않은 이유는 두 가지다. 첫째, 자르는 위치 자체가 매 카운터마다 HMAC 출력에 종속되므로 "항상 앞 4바이트만 본다"는 가정이 성립하지 않는다. 둘째, 오프셋을 결정하는 마지막 바이트와 실제로 잘려나가는 4바이트를 합치면 20바이트 중 최소 5바이트가 최종 값에 관여한다 — 고정 위치 절단이었다면 나머지 바이트는 계산에는 참여해도 결과에는 흔적을 남기지 못했을 것이다.

4. 예시 — 직접 돌려보기

RFC 4226 Appendix D는 비밀키를 "12345678901234567890"(20바이트)으로 고정하고 카운터별 결과를 표로 제공한다. 아래 Python 스니펫으로 그대로 재현할 수 있다.

import hmac, hashlib, struct

def hotp(key: bytes, counter: int, digits: int = 6):
    c = struct.pack(">Q", counter)                     # 8바이트 big-endian 카운터
    h = hmac.new(key, c, hashlib.sha1).digest()         # 20바이트 HMAC-SHA-1
    offset = h[19] & 0x0F                               # 마지막 바이트 하위 4비트 = 오프셋
    p = int.from_bytes(h[offset:offset + 4], "big") & 0x7FFFFFFF
    return p % (10 ** digits), offset

key = b"12345678901234567890"
for counter in range(3):
    code, offset = hotp(key, counter)
    print(counter, offset, code)

counter=0에서 755224, counter=1에서 287082가 나오면 Appendix D 표의 처음 두 행과 일치한다. offset을 같이 출력해보면 카운터마다 0~15 사이에서 들쭉날쭉하게 바뀌는 걸 직접 볼 수 있다 — "항상 같은 자리를 자른다"는 가정이 왜 틀렸는지 코드로 확인되는 지점이다.

이 구조를 몰랐을 때 내가 가진 오해는 "고정된 앞 4바이트를 잘라서 숫자로 바꾼다"였다. 고정 위치였다면 구현이 훨씬 단순했겠지만, 그만큼 HMAC 출력에서 실제로 최종 값에 반영되는 바이트 수도 줄어든다. 동적 절단은 오프셋 자체도 결과에 영향을 미치는 요소로 편입시켜, 값의 예측 난이도를 HMAC-SHA-1의 계산 불가능성 전체에 의존시킨다. mod 10^6로 자릿수를 줄이는 것도 마찬가지다 — 최종 값의 후보가 100만 개로 줄어들 뿐, 그 값을 역산하려면 여전히 SHA-1을 뒤집어야 한다.

5. 정리

HOTP는 결국 "HMAC 출력에서 자기 자신이 가리키는 위치를 잘라 31비트로 접고 mod로 자릿수를 맞추는" 한 줄짜리 절차이고, TOTP는 그 카운터를 시간으로 바꿔 끼운 것뿐이다. 다음에 살펴볼 만한 지점은 WebAuthn/FIDO2의 challenge-response 방식이 이런 공유 비밀 기반 OTP보다 피싱에 왜 근본적으로 더 강한지다 — 공유 비밀과 공개키 서명의 차이.

참고 자료

  • RFC 4226 §5(알고리즘), §5.3(Dynamic Truncation), Appendix D(테스트 벡터)
  • RFC 6238 §4(TOTP 알고리즘)
  • RFC 2104 (HMAC)

0개의 댓글