← 기술 지식

기술 지식 / 보안 / 파일·외부 연동

HMAC 서명 canonicalization·키 분리

서명할 바이트의 canonicalization과 HMAC 키 용도 분리를 통해 검증자 간 의미 차이를 줄입니다.

보안 · 원리·구현·운영

자료 대조일 · 적용 버전과 실행 여부는 본문의 검증 범위에 명시합니다.

기술 지식 트리

HMAC을 붙였다는 사실만으로 요청 계약이 완성되지는 않습니다. HMAC은 의미가 같은 JSON이나 “비슷해 보이는” 헤더를 비교하는 알고리즘이 아니라, 특정 비밀키와 정확한 메시지 바이트에 대한 메시지 인증 코드(MAC)입니다. 따라서 송신자와 검증자가 같은 바이트를 선택하고, 그 바이트를 만드는 규칙과 키의 용도를 고정해야 합니다. 그 다음에야 태그 비교, 시각과 재생 방지, 권한 검사가 독립된 층으로 작동합니다.

이 장의 범위는 네 가지입니다. 첫째, raw body를 그대로 서명할지, 프로토콜이 정한 canonical representation을 만들지 선택하는 방법입니다. 둘째, HMAC 내부에서 긴 키와 출력 절단을 다루는 규칙입니다. 셋째, 태그 비교와 재생 방지는 서로 다른 문제라는 점입니다. 넷째, webhook과 session 서명처럼 의미가 다른 경계에 비밀을 재사용하지 않는 키 계층입니다. RFC 2104는 HMAC 구성과 키 처리의 기준을 제시하고, RFC 9421은 HTTP 구성요소를 어떤 순서와 정규화로 signature base에 넣을지의 계약을 보여 줍니다. 애플리케이션의 JSON canonicalization 자체를 두 RFC가 자동으로 결정해 주는 것은 아닙니다.

바이트 경계와 의미 경계

가장 단순한 계약은 받은 HTTP body의 바이트를 바꾸지 않고 HMAC(K, body)를 계산하는 것입니다. 수신 서버는 body-parser가 JSON 객체를 먼저 만들기 전에 원문을 보존해야 합니다. 다음 두 body는 애플리케이션이 같은 객체로 읽을 수 있지만 바이트가 다릅니다.

A = {"amount":100,"currency":"KRW"}
B = { "currency": "KRW", "amount": 100 }

공급자가 A를 서명했는데 서버가 B를 다시 직렬화해 검증하면 MAC 입력이 달라집니다. 숫자도 1, 1.0, 지수 표기, 유니코드 escape에 따라 달라질 수 있습니다. 이 경우 “JSON 의미가 같으니 서명도 같아야 한다”는 기대가 구현 계약을 대신할 수 없습니다.

원문 서명이 불가능하고 여러 구현이 하나의 구조를 교환해야 한다면 canonicalization 규칙을 별도 계약으로 고정합니다. 키 정렬, 공백 제거, 문자열 escape, 숫자 표현, 유니코드 정규화, 허용 필드, 중복 키 거부를 문서화하고 테스트 벡터를 함께 배포해야 합니다. 파서마다 중복 JSON 키를 첫 값으로 읽을지 마지막 값으로 읽을지 다르면 서명 전에 이미 의미가 갈라집니다. 애매한 입력을 canonicalize해서 살리는 것보다 거부하는 편이 검증 경계에 유리합니다.

signature base 구성

HTTP 서명은 body 하나만의 문제가 아닙니다. 어떤 메서드, 대상 URI, host, content-digest, timestamp를 서명하는지, 각 값을 어떤 표준 표현으로 결합하는지에 따라 검증 대상이 달라집니다. RFC 9421의 signature base는 covered component와 그 식별자·파라미터를 바탕으로 송신자와 수신자가 독립적으로 재구성합니다. HTTP 메시지 전체를 그대로 믿는 것이 아니라, 양쪽이 같은 구성요소 집합과 canonicalization을 적용해야 한다는 사고방식입니다.

예를 들어 다음 계약을 정했다고 하겠습니다.

"@method": POST
"@target-uri": https://api.example.test/v1/events
"content-digest": sha-256=:...
"x-event-id": evt-42

검증자는 프록시가 바꿀 수 있는 Host를 무비판적으로 신뢰하지 않고, 실제 요청 대상과 전달 경계를 명확히 합니다. 서명에 포함하지 않은 헤더는 무결성이 보호되지 않습니다. 반대로 너무 많은 변동 필드를 포함하면 재시도, 프록시, canonicalization 구현 차이로 정상 요청이 깨집니다. 무엇을 보호할지보다 어떤 변환을 거친 뒤 최종 의미가 정해지는지를 먼저 그립니다.

서명 입력을 먼저 고정하고 의미 검사를 뒤에 둡니다원문 body·지정 헤더: 수신 바이트 보존. canonical signature base: 구성요소·순서·표현. HMAC 생성·태그 비교: 키 버전·허용 길이. 의미·재생·권한 검사: event ID·시각·대상합의된 변환동일 바이트 입력진위 확인 후 해석원문 body·지정 헤더수신 바이트 보존canonical signature base구성요소·순서·표현HMAC 생성·태그 비교키 버전·허용 길이의미·재생·권한 검사event ID·시각·대상
서명 입력을 먼저 고정하고 의미 검사를 뒤에 둡니다 검증자는 동일한 서명 입력을 만들고 태그를 비교한 뒤에야 JSON 의미와 재생 정책을 적용합니다.
그림을 글로 읽기
  • 원문 body·지정 헤더: 수신 바이트 보존
  • canonical signature base: 구성요소·순서·표현
  • HMAC 생성·태그 비교: 키 버전·허용 길이
  • 의미·재생·권한 검사: event ID·시각·대상
  1. 원문 body·지정 헤더 → canonical signature base: 합의된 변환
  2. canonical signature base → HMAC 생성·태그 비교: 동일 바이트 입력
  3. HMAC 생성·태그 비교 → 의미·재생·권한 검사: 진위 확인 후 해석

HMAC 내부와 키 길이

RFC 2104의 HMAC은 해시의 블록 크기 B와 출력 길이 L을 기준으로 키를 정리합니다. 키가 B보다 길면 먼저 해시해 짧은 키로 바꾸고, 짧으면 B 바이트가 되도록 0을 채운 뒤 ipad와 opad를 각각 XOR합니다. 개념식은 H((K' XOR opad) || H((K' XOR ipad) || text))입니다. 이 규칙은 애플리케이션이 장황한 비밀 문자열을 넣었다고 해서 HMAC이 그 문자열 전체를 직접 패딩한다는 뜻이 아님을 보여 줍니다.

예를 들어 SHA-256의 블록 크기는 64바이트이고, 100바이트 비밀을 넣으면 먼저 SHA-256 결과인 32바이트로 축약한 뒤 내부·외부 패딩에 사용합니다. 설명용 중간 상태는 다음과 같습니다.

K_raw       = 100 bytes
K'          = SHA-256(K_raw) = 32 bytes
K_block     = K' || 32 zero bytes = 64 bytes
inner       = SHA-256((K_block xor ipad) || message)
full_tag    = SHA-256((K_block xor opad) || inner) = 32 bytes

이것은 임의 길이 비밀을 “더 강하게” 만드는 기능이 아닙니다. 실제 보안 강도는 키의 예측 불가능성, 길이, 보관과 회수, 선택한 해시와 태그 길이에 의해 결정됩니다. 별도 프로토콜이 HMAC 출력 일부만 보낼 수는 있지만, 그때는 t비트 태그 길이와 허용 인코딩을 양쪽이 고정해야 합니다. 접두사 몇 글자만 보내고 수신자가 입력 길이에 맞춰 무엇이든 받아 주는 방식은 합의된 truncation이 아닙니다.

키 용도와 domain separation

webhook 검증키와 session 서명키를 하나로 재사용하면 알고리즘이 같은 것보다 더 큰 문제가 생깁니다. 두 검증자가 같은 비밀을 알고 있고 서로 다른 문자열 형식이나 필드 해석을 사용하면 한 경계에서 발급된 MAC을 다른 경계가 받아들이는 혼동 가능성이 커집니다. 한 서비스가 침해되어 키가 노출될 때 회수 범위도 두 자격 체계로 확장됩니다.

가장 이해하기 쉬운 선택은 물리적으로 다른 키를 저장하는 것입니다. 키 관리 시스템에서 hmac/webhook/v1과 hmac/session/v1을 다른 항목으로 두고 접근 주체를 나눕니다. 파생 키를 사용한다면 K_webhook = KDF(K_root, "webhook-v1"), K_session = KDF(K_root, "session-v1")처럼 context와 길이를 명시하고, 파생 함수와 입력 인코딩을 문서화합니다. 단순히 payload 앞에 webhook-v1 문자열을 붙이는 것은 키 접근 범위와 회수 범위를 분리하지 않으므로 키 분리를 대체하지 못합니다.

키 ID는 공개 식별자일 수 있지만 비밀이 아닙니다. kid=webhook-2026-09가 있어도 실제 키를 찾는 서버 측 매핑과 권한 검사가 필요합니다. 모르는 kid를 요청으로 받은 URL에서 가져오거나, 검증 실패를 피하려고 기본 키를 추측하는 동작은 허용하지 않습니다. 회전할 때도 검증자에 새 키를 먼저 준비하고, 발급자를 전환하고, 수명과 재전송 창이 지난 뒤 옛 키를 제거하는 정상 경로와, 유출 즉시 옛 키를 폐기하는 긴급 경로를 분리합니다.

비교와 재생의 별도 경계

태그가 같은지 확인할 때 일반 문자열 비교의 조기 종료가 항상 원격 공격으로 측정된다고 단정할 수는 없지만, 검증 API는 길이를 확인하고 constant-time 비교를 제공하는 라이브러리를 사용하는 편이 안전합니다. 비교 대상은 디코딩한 고정 길이 바이트로 맞추고, 허용되지 않은 hex/base64 형식이나 길이는 먼저 거부합니다. constant-time 비교는 MAC 입력의 canonicalization 오류를 고치지 않고, 키를 안전하게 보관해 주지도 않습니다.

또한 성공한 HMAC을 그대로 재사용할 수 있는지와 태그가 맞는지는 다른 질문입니다. event_id, 서명된 timestamp, 허용 시각 오차, nonce 또는 내구 저장된 중복 마커가 있어야 같은 진짜 요청의 재전송을 제한할 수 있습니다. 단순히 헤더의 시간을 별도로 읽어 검사하면 공격자가 서명되지 않은 시각을 바꿀 수 있으므로 시간 값이 signature base에 포함되었는지 확인합니다. 재시도 창이 5분이라면 저장된 event ID의 보존 기간도 그보다 짧지 않아야 합니다.

구현 선택과 실패 처리

구현 순서는 입력 크기와 헤더 길이 제한, 원문 보존, 서명 형식 파싱, 허용 알고리즘·키 ID 확인, canonical bytes 생성, MAC 계산, 고정 길이 비교, 서명된 시간과 중복 검사, JSON 의미 해석, 권한 검사 순서가 됩니다. 실패 응답은 공격자에게 키 존재 여부나 유효한 event ID를 알려 주지 않도록 동일한 외부 오류 범주로 묶고, 내부 로그에는 비밀 대신 키 버전과 추적 ID를 기록합니다.

테스트 벡터는 최소한 키가 32바이트인 정상 입력, 블록보다 긴 키, 빈 body, JSON 공백·키 순서 변경, 숫자 표기 변경, 중복 키, 잘못된 인코딩, 한 바이트가 잘린 태그, 태그 접두사만 보낸 입력을 포함합니다. 구현을 실행하지 않았다면 위 중간 상태와 결과는 설명용 계산이며 성공한 실행 결과라고 쓰지 않습니다. 실제 수신 경로에서는 raw body가 middleware를 거치며 바뀌지 않는지, reverse proxy가 서명된 URL 구성요소를 변경하지 않는지 별도로 확인합니다.

비용과 한계

canonicalization은 서로 다른 언어 구현이 같은 의미를 얻는 데 도움을 주지만 규칙 자체가 복잡해질수록 parser discrepancy와 버전 호환 비용이 커집니다. raw bytes 계약은 단순하지만 중간 프록시나 SDK가 body를 재직렬화하면 연동이 깨집니다. 장기적인 공개 API라면 canonical form과 테스트 벡터가 낫고, 한 공급자의 webhook처럼 원문 전달을 보장할 수 있다면 raw body 검증이 더 좁은 계약일 수 있습니다.

HMAC은 무결성과 공유키를 가진 발급자라는 근거를 제공하지만, 누가 그 요청을 수행할 권한이 있는지, 이벤트가 새로운지, TLS 채널 상대가 기대한 서버인지까지 증명하지 않습니다. 키가 노출되면 attacker가 유효 태그를 만들 수 있고, 짧은 태그는 추측 예산에 취약해집니다. 따라서 서명 성공을 최종 효과로 바로 연결하지 말고, 입력 경계·키 경계·재생 경계·업무 인가를 각각 로그와 테스트로 검증해야 합니다.

구현 계약과 검산

구현은 먼저 rawBody와 파싱 객체를 분리해 보관하는 어댑터에서 시작합니다. 예를 들어 입력이 {"b":2,"a":1}이면 raw 바이트의 길이와 SHA-256 digest를 로그의 비밀 없는 지문으로 남기고, canonical 계약을 채택한 경우에만 {"a":1,"b":2}로 변환합니다. 중복 키 {"a":1,"a":2}는 첫 값·마지막 값 선택을 구현마다 다르게 만들 수 있으므로 canonicalizer에 넘기기 전에 거부합니다. 이 작은 상태 추적을 테스트 벡터로 고정하면 JSON parser를 교체해도 signature base가 바뀌었는지 바로 알 수 있습니다.

검증 코드는 kid를 내부 allowlist에서 조회하고, 허용된 해시·태그 길이·인코딩을 확인한 뒤 MAC을 계산합니다. 길이가 32바이트인 SHA-256 태그를 hex 64자로 받기로 했다면 base64 문자열을 같은 단계에서 받아들이지 않습니다. 실패는 bad_signature 같은 한정된 외부 결과로 합치되, 내부에는 canonicalization 실패인지 키 미존재인지와 버전만 기록합니다. webhook 키 노출과 session 키 노출을 같은 회수 작업으로 처리하지 않도록 secret store 권한과 rotation runbook도 별도로 둡니다.

작은 검산으로 SHA-256 블록보다 긴 100바이트 키는 먼저 32바이트 digest가 되고, 이후 64바이트 블록에 32바이트 zero padding이 붙습니다. 이 단계는 긴 키를 임의로 자르는 것과 다릅니다. 실제 HMAC 출력이나 상수 시간 성질은 도입 언어 API로 별도 실행·문서 확인해야 하며, 이 문서의 수치는 설명용 중간 상태입니다.

참고 자료와 확인 범위

  • RFC 2104, HMAC: Keyed-Hashing for Message Authentication. HMAC의 ipad·opad, 블록 크기보다 긴 키의 해시 처리, 출력 절단 설명을 확인했습니다.
  • RFC 9421, HTTP Message Signatures. covered component와 서명 base를 송신자·검증자가 독립적으로 구성하는 규칙을 확인했습니다. 이 RFC가 애플리케이션 JSON의 canonicalization을 자동 결정하지 않는다는 범위를 유지했습니다.
  • 이 장의 코드 블록과 중간 상태는 설명용이며 실행 결과가 아닙니다. SHA-256 프로필의 실제 블록 크기와 운영 라이브러리의 입력·인코딩 계약은 도입하는 API 문서를 추가로 확인해야 합니다.

후속·관련 개념

함께 읽을 문서

분야별 학습 경로

전체 기술 지식