HTTP 메시지 서명(RFC 9421): Signature-Input은 실제로 무엇에 서명하나

RFC 9421의 Signature-Input과 Signature가 각각 무엇을 담는지, 서명 기반이 어떻게 만들어지는지, keyid·created·nonce와 Content-Digest(RFC 9530)가 보장하지 않는 것까지 정리합니다.

securityhttphttp-message-signaturesrfc-9421

요청에 Signature-Input과 Signature 헤더가 붙어 있고 검증도 통과했다고 하자. 그렇다고 요청 전체가 서명된 것은 아니다. RFC 9421의 HTTP 메시지 서명은 메시지에서 고른 일부 구성 요소(component)로 “서명 기반(signature base)“이라는 문자열을 만들고, 그 문자열에 서명한다. 무엇을 골랐는지는 Signature-Input에 적혀 있고, 거기에 없는 부분은 서명과 무관하게 바뀔 수 있다. 검증이 통과했을 때 무엇이 보장되고 무엇이 보장되지 않는지는 이 구조를 알아야 판단할 수 있다.

이 글은 RFC 9421 HTTP Message Signatures와 본문 다이제스트 필드를 정의한 RFC 9530 Digest Fields를 근거로 한다. 두 문서 모두 2024년 2월에 나온 IETF Standards Track 문서이고(RFC Editor 표기는 Proposed Standard), 2026-10-20에 확인했다. 그날 RFC Editor 정보 페이지 기준으로 두 문서를 갱신하거나 대체한 문서는 없고, RFC 9530은 RFC 3230을 대체(obsolete)한다고 표시되어 있다. RFC 9421에는 확인된(Verified) 정오표가 두 건 있는데, 7.2.8절과 7.5.3절에 적힌 @signature-input을 @signature-params로 고친다는 내용이다. 특정 라이브러리나 서비스의 구현 방식은 다루지 않는다.

두 필드: Signature-Input과 Signature

RFC 9421 4절에 따르면 Signature-Input은 서명이 어떤 구성 요소를 덮고(covered components) 어떤 매개변수로 만들어졌는지를 나타내고, Signature는 서명 값 자체를 담는다. 둘 다 Structured Fields의 Dictionary 형식이고, 사전의 키가 서명을 가리키는 레이블(label)이다.

  • Signature-Input의 값은 덮는 구성 요소 식별자의 순서 있는 목록(Inner List)에 created, keyid 같은 서명 매개변수를 붙인 것이다.
  • Signature의 값은 서명 바이트를 담은 Structured Fields의 Byte Sequence다. RFC의 직렬화된 예시에서는 그 Base64 값이 콜론으로 구분되어 있다.
  • 한 서명은 두 필드를 모두 써야 하고, 두 필드에 같은 레이블이 있어야 한다. 한쪽에만 있는 레이블은 오류다.

레이블 자체는 서명 대상이 아니다. 7.2.5절은 중간자가 메시지를 처리하면서 레이블을 바꿀 수 있으므로 애플리케이션이 특정 레이블에 의미를 두지 말라고 한다. 서명의 용도를 구분해야 한다면 서명 매개변수인 tag를 쓴다. 다만 tag 값도 공개되어 있어 공격자가 똑같이 쓸 수 있으므로(7.2.7절), 검사할 서명을 고르는 용도로만 써야 한다.

아래는 RFC 9421 3.2절의 검증 예시 요청이다. RFC의 줄바꿈 표기(RFC 8792의 \)를 풀어 한 줄로 이었고, 길이가 긴 Signature 필드는 이 발췌에서 뺐다. 시험용 키(test-key-rsa-pss)를 쓰는 비규범적 예시이며, 이 글을 쓰면서 직접 서명하거나 검증해 보지는 않았다.

POST /foo?param=Value&Pet=dog HTTP/1.1
Host: example.com
Date: Tue, 20 Apr 2021 02:07:55 GMT
Content-Type: application/json
Content-Digest: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:
Content-Length: 18
Signature-Input: sig1=("@method" "@authority" "@path" "content-digest" "content-length" "content-type");created=1618884473;keyid="test-key-rsa-pss"

{"hello": "world"}

서명되는 것은 서명 기반이다

서명자와 검증자는 각자 가진 메시지에서 서명 기반을 따로 만든다. 1.2절은 그 이유를 이렇게 설명한다. HTTP는 중간자가 메시지를 변형하는 것을 허용하고 때로는 요구하기 때문에 받은 쪽의 메시지가 보낸 쪽과 비트 단위로 같다고 기대할 수 없고, 그래서 원래 전송된 바이트(raw bytes)를 서명 기반으로 쓸 수 없다.

위 요청에서 2.5절의 알고리즘으로 만든 서명 기반은 다음과 같다(RFC 9421 Figure 1, 줄바꿈 표기를 풀었다).

"@method": POST
"@authority": example.com
"@path": /foo
"content-digest": sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:
"content-length": 18
"content-type": application/json
"@signature-params": ("@method" "@authority" "@path" "content-digest" "content-length" "content-type");created=1618884473;keyid="test-key-rsa-pss"

만드는 규칙은 단순하다.

  • Signature-Input에 적힌 순서대로 한 줄씩, "구성 요소 식별자": 값 형태로 쓰고 줄 끝에 LF 하나를 붙인다.
  • @로 시작하는 이름은 파생 구성 요소(derived component)다. @method는 메서드, @authority는 대상 URI의 authority, @path는 쿼리를 뺀 경로처럼 메시지의 제어 데이터에서 값을 뽑는다(2.2절).
  • 나머지는 HTTP 필드다. 이름은 소문자로 쓰고, 같은 필드가 여러 번 오면 값을 쉼표와 공백 하나(, )로 이어 붙인다(2.1절).
  • 마지막 줄은 항상 "@signature-params"이고, 그 값은 Signature-Input의 해당 항목 값(레이블 제외)과 같은 직렬화다. 마지막 줄 뒤에는 줄바꿈을 붙이지 않는다.
  • 덮기로 한 필드가 메시지에 없거나 값을 구할 수 없으면 서명 기반을 만들지 않고 오류로 끝낸다.

알고리즘은 이 바이트열에 서명한다. 3.3절은 서명 함수를 HTTP_SIGN(M, Ks), 검증 함수를 HTTP_VERIFY(M, Kv, S)로 정의하는데, 여기서 M이 서명 기반이다. 마지막 줄이 매개변수 전체를 담기 때문에 created나 keyid를 바꾸면 서명 기반도 바뀐다. 3.1절은 이 덕분에 서명이 언제나 자기 메타데이터를 덮고, 메타데이터만 바꿔 끼울 수 없다고 설명한다.

예시 서명 기반을 원래 요청과 비교하면 빠진 것이 보인다.

  • 요청에는 Date 헤더가 있지만 덮는 목록에 없으므로 서명 기반에 나오지 않는다.
  • 요청 대상은 /foo?param=Value&Pet=dog인데 @path의 값은 /foo다. 쿼리 문자열은 @query나 @query-param을 따로 넣어야 덮인다.
  • 본문 {"hello": "world"}는 서명 기반에 없다. 들어 있는 것은 Content-Digest 필드의 값이다.

1.3절은 서명이 덮지 않은 구성 요소의 변경은 검증을 막지 않아야 한다고 적는다. 위 예에서 쿼리 문자열이나 Date가 바뀌어도 서명 자체는 그대로 검증된다는 뜻이다.

무엇을 덮을지가 보안을 정한다

7.2.1절은 서명이 덮지 않은 부분은 서명에 영향을 주지 않고 공격자가 바꿀 수 있다고 경고한다. 공격자가 처리 결과를 바꾸는 헤더를 넣거나 고쳐도 서명은 여전히 유효하다. 그래서 애플리케이션은 가능한 한 많은 부분을 서명하도록 요구하고, 검증자는 서명된 구성 요소만 믿는 것이 좋다고 이 절은 권한다.

극단적인 경우가 덮는 목록이 비어 있는 서명이다. 부록 B.2.1의 예시 Signature-Input: sig-b21=();created=...;keyid=...;nonce=...는 메시지의 어떤 부분도 덮지 않는다. RFC는 이런 서명을 가로챈 공격자가 관련 없는 다른 메시지에 붙일 수 있으므로 빈 목록은 권장하지 않는다고 적는다.

서명자 쪽 권고도 있다(3.1절, 7.2.4절).

  • 요청 서명자는 @method, @authority, @target-uri 같은 제어 데이터를 일부 또는 전부 덮는 것이 좋다(SHOULD).
  • 서명 생성 시각을 나타내는 created 매개변수를 넣는 것이 좋다(SHOULD).
  • Host 헤더 대신 @authority를, 시간 판단에는 Date 헤더 대신 created를 쓰는 편이 일반적으로 낫다.

반대로 너무 많이 덮으면 정상적인 전달 과정에서 서명이 깨진다. 7.2.3절은 Via와 Forwarded처럼 프록시가 고치거나 지우는 필드는 아주 제한된 경우가 아니면 덮지 말라고 하고, 쿼리 파라미터를 다시 쓰는 프레임워크라면 @query 대신 개별 @query-param을 쓰는 방법을 예로 든다. 1.3절에 따르면 필드 순서 변경이나 HTTP/1.1과 HTTP/2 사이의 변환처럼 덮은 구성 요소에 일어나도 검증을 막지 않아야 하는 변형도 있지만, 덮은 필드의 값을 파싱했다가 다시 직렬화하거나 파생 구성 요소의 값을 바꾸면 검증이 실패할 수 있다.

리버스 프록시 뒤의 서버가 대표적이다. 프록시가 Host를 내부 주소로 바꾸면 서버가 받은 메시지의 @authority는 클라이언트가 서명한 값과 달라진다. 7.4.3절은 서버가 클라이언트 쪽에서 본 외부 대상 URI를 설정으로 알고 그 값으로 구성 요소를 만드는 방법과, 프록시가 클라이언트 서명을 검증한 뒤 자기 서명을 추가하는 방법(4.3절)을 소개한다. 어느 쪽이든 서명자와 검증자가 같은 구성 요소 규칙을 공유해야 한다. 1.4절이 RFC 9421을 쓰는 애플리케이션이나 프로파일에 덮어야 할 구성 요소와 매개변수, 키를 찾는 방법, 허용 알고리즘 등을 반드시(MUST) 정하라고 요구하는 것도 이 때문이다.

keyid는 키를 가리킬 뿐이다

keyid는 2.3절에서 “키 자료의 식별자”인 문자열로 정의된다. 서명자가 어떤 키를 썼는지 검증자에게 알려 주는 힌트일 뿐, 그 키가 누구의 것인지 증명하지는 않는다. 누구든 자기 키로 서명하고 원하는 keyid 문자열을 적을 수 있다.

3.2절의 검증 절차 5단계는 검증자가 키 식별자를 실제 키 자료로 풀어내되, 그 키를 이 맥락에서 믿을 수 있는지 스스로 판단해야 한다고 적는다. 모르는 키, 이 요청에 대해 신뢰하지 않는 키, 미리 설정된 것과 맞지 않는 키라면 검증은 반드시 실패해야 한다(MUST). 7.3.4절은 같은 내용을 반대쪽에서 설명한다. 유효한 서명이 있다는 것만으로는 적절한 당사자가 서명했다는 증거가 되지 않으며, 검증자가 키와 알고리즘의 적절성을 확인하지 않으면 공격자가 자기 키로 서명한 메시지를 받아들이게 된다. 한 메시지에 여러 서명이 붙을 수 있으므로, 암호학적으로 유효한지와 상관없이 기대한 서명자의 서명만 받아들여야 한다(7.2.6절).

알고리즘도 같은 원칙을 따른다.

  • 검증자는 애플리케이션이 허용한 알고리즘 집합에서 출발하고, 그 밖의 알고리즘이 선택되면 검증은 실패한다. 설정, 키 자료, alg 매개변수처럼 여러 곳에서 알고리즘이 정해지면 모두 같아야 한다(3.2절 6단계).
  • 7.3.6절은 다운그레이드의 예로, RSA-PSS를 기대하는 RSA 키에 RSA PKCS#1 v1.5 서명을 받아들이는 경우와, 공개 RSA 키를 HMAC 키로 써서 공격자가 만든 HMAC 서명을 검증해 버리는 경우를 든다. 그래서 런타임 alg 값보다 정적 설정 같은 다른 방법으로 알고리즘을 정하라고 권한다.
  • HMAC처럼 대칭 키를 쓰면 검증자도 같은 키로 유효한 서명을 만들 수 있다. 검증자가 뚫리면 서명자를 사칭할 수 있게 된다(7.3.3절).

암호학적 검증이 성공해도 알 수 있는 것은 “이 키로 이 구성 요소들에 서명했다”까지다. 그 키의 주인이 이 작업을 해도 되는지는 별개의 판단이다. RFC 9421은 1.4절에서 이 명세가 전체 보안 시스템의 일부만 정의한다고 밝히고, RFC 9530도 1.2절에서 인증, 인가, 프라이버시 수단을 정의하지 않는다고 적는다. 서명을 인증 수단으로 쓸 때의 실패 응답으로 1.4절은 401이나 403을 예로 드는데, 두 코드의 차이는 401과 403의 차이에서 정리했다.

created, expires, nonce와 재전송

2.3절의 매개변수 가운데 시간과 재전송에 관련된 것은 셋이다.

매개변수 형식 RFC 9421이 정한 의미
created 정수(UNIX 타임스탬프, 초 단위) 서명 생성 시각. 넣는 것을 권장(RECOMMENDED)
expires 정수(UNIX 타임스탬프, 초 단위) 만료 시각. 서명자가 더는 서명을 보증하지 않겠다는 시점을 알리는 힌트
nonce 문자열 이 서명을 위해 만든 무작위 고유 값

이 값들은 서명 기반의 마지막 줄에 들어가므로 바꾸면 서명이 깨진다. 하지만 값이 있다는 것만으로 재전송이 막히지는 않는다. 7.2.2절은 메시지의 일부만 서명할 수 있는 이상 서로 다른 두 메시지가 같은 서명으로 검증될 수 있고, 덮는 범위가 충분해도 비슷한 메시지에 서명을 그대로 붙여 재전송할 수 있다고 설명한다. 대응책으로 드는 것은 다음과 같다.

  • 다른 메시지와 구별될 만큼 충분히 덮는다.
  • nonce를 넣으면 검증자가 같은 값이 반복될 때 재전송을 알아챌 수 있다. 반복을 알아채려면 검증자가 이미 본 값을 기억하고 있어야 한다.
  • created와 expires는 가로챈 서명을 쓸 수 있는 기간을 줄여 준다.

3.2.1절은 최대 서명 나이 강제, expires가 지난 서명 거부, nonce 고유성 강제를 모두 애플리케이션이 추가로 정할 수 있는 요구 사항의 예로 든다. 즉 허용할 시간 범위, 시계 오차, nonce를 얼마나 오래 기억할지는 애플리케이션 정책이고 RFC는 수치를 정하지 않는다. expires는 힌트이므로 검증자는 만료 전이라도 서명을 거부할 수 있다. 부록 B.2.1은 nonce가 같은 서명의 반복을 막더라도, 공격자가 서명을 가로채 원래 수신자에게 전달되지 않게 하면 그 서명을 다른 메시지에 쓸 수 있다고 덧붙인다.

7.1.2절은 메시지 서명이 TLS를 대신하지 않는다고 적는다. 서명은 덮은 구성 요소의 무결성을 줄 뿐 기밀성은 주지 않고, TLS는 서명 데이터 자체가 가로채이는 것을 막아 재전송 방지에도 한몫한다. 검증자가 서명자에게 새 서명을 요구하고 싶다면 새 nonce를 담은 Accept-Signature 필드를 보낼 수 있다(7.2.2절).

본문은 Content-Digest를 통해서만 묶인다

RFC 9421은 1절과 7.2.8절에서 이 명세만으로는 HTTP 메시지 본문(content)을 서명으로 덮지 않으며, 본문의 해시는 Digest 명세(RFC 9530)에 맡긴다고 밝힌다.

RFC 9530 2절의 Content-Digest는 실제 메시지 콘텐츠에 해시 알고리즘을 적용한 다이제스트를 담는 필드다. Dictionary 형식이고 키는 알고리즘(sha-256, sha-512 등), 값은 다이제스트 바이트의 Byte Sequence다. 같은 문서 3절의 Repr-Digest는 메시지 콘텐츠가 아니라 선택된 표현 데이터(selected representation data) 전체에 대한 다이제스트다. Range 요청에 대한 부분 응답이나 HEAD 응답처럼 메시지에 실린 콘텐츠와 표현 데이터가 다를 때 이 구분이 의미를 가진다. RFC 9530 1.3절은 옛 Digest 필드(RFC 3230)를 구현마다 콘텐츠 기준으로, 또는 표현 기준으로 다르게 해석한 것이 대체의 주된 이유였다고 설명한다.

Content-Digest를 붙이는 것만으로는 서명과 아무 관계가 없다. 서명이 본문을 묶으려면 다음이 모두 필요하다.

  1. content-digest가 Signature-Input의 덮는 목록에 들어 있어야 한다. 그래야 다이제스트 값이 서명 기반에 들어간다.
  2. 검증자가 받은 본문으로 다이제스트를 다시 계산해 필드 값과 비교해야 한다. 7.2.8절은 서명이 직접 덮는 것은 필드 값뿐이므로, 이 단계를 건너뛰면 공격자가 Content-Digest는 그대로 두고 본문만 바꿔치기할 수 있다고 경고한다.
  3. 다이제스트가 기대는 표현 메타데이터도 함께 덮는 것이 좋다. RFC 9530 6.3절은 다이제스트가 Content-Type, Content-Encoding 같은 값에 의존하므로, 다이제스트 필드만 서명하고 이 값들을 서명하지 않으면 공격자가 Content-Type을 바꿔 수신 측 다이제스트 검증을 실패하게 만들 수 있다고 적는다.

위 예시가 content-digest와 함께 content-type, content-length를 덮은 것은 이런 맥락에서 읽을 수 있다. 3.2절의 예시도 검증이 통과하려면 메서드, authority, 경로, content-digest, content-length, content-type이 서명되어 있고 생성 시각이 충분히 최근이어야 한다는 애플리케이션 요구를 함께 전제한다.

몇 가지 제약이 더 있다.

  • Content-Digest 값은 콘텐츠 인코딩에 따라 달라진다. 중간자가 콘텐츠 인코딩을 바꾸면 다이제스트가 바뀌고 서명도 무효가 되므로, 그런 중간자는 새 다이제스트로 다시 서명해야 한다(RFC 9421 7.2.8절).
  • 서명에 쓰는 경우는 적대적인 환경으로 볼 가능성이 높다(RFC 9530 6.3절). 이런 환경에서는 레지스트리에서 “Deprecated”인 알고리즘(md5, sha 등)을 쓰면 안 되고(MUST NOT), 초기 레지스트리에서 “Active”인 것은 sha-256과 sha-512다(RFC 9530 5절, 7.2절).
  • 다이제스트만으로는 악의적인 변조를 막지 못한다. 경로 위의 공격자가 다이제스트를 지우거나 조작한 본문으로 새로 계산해 넣을 수 있으므로, TLS나 서명과 함께 써야 한다(RFC 9530 6.1절).
  • Signature와 Signature-Input은 트레일러로 보낼 수도 있지만, 중간자가 트레일러를 버릴 수 있으므로 헤더로만 보내는 것이 권장된다(RFC 9421 4.1절, 4.2절).

검증자가 확인할 것

아래 목록은 RFC 9421 3.2절의 검증 절차에 3.2.1절과 7절의 고려 사항, RFC 9530의 내용을 붙여 정리한 것이다. 순서와 묶음은 이 글의 정리이고 RFC가 정한 형식은 아니다.

  1. Signature와 Signature-Input에서 같은 레이블의 쌍을 찾고, 여러 서명 중 처리할 서명을 정책에 따라 고른다.
  2. 덮는 목록에 애플리케이션이 요구하는 구성 요소(제어 데이터, content-digest, 인가 관련 헤더 등)가 모두 있는지 확인한다.
  3. keyid나 사전 설정으로 키를 찾고, 그 키를 이 요청에 대해 신뢰하는지 확인한다. 모르는 키면 실패다.
  4. 알고리즘을 허용 목록에서 정하고, 여러 곳에서 정해진 값이 서로 같은지 확인한다.
  5. 받은 메시지에서 서명 기반을 다시 만든다. 프록시 뒤라면 어떤 대상 URI를 기준으로 할지 정해 둔다.
  6. 알고리즘의 검증 함수로 서명을 검증한다. RSA-PSS나 ECDSA처럼 비결정적인 알고리즘은 같은 기반에 다시 서명해 결과를 비교하는 식으로 검증할 수 없다(7.3.5절).
  7. created, expires, nonce를 애플리케이션 정책에 맞춰 확인한다.
  8. 받은 본문으로 Content-Digest를 다시 계산해 비교한다.
  9. 키의 주인에게 요청한 작업을 할 권한이 있는지 확인한다.

7.1.1절은 서명이 없어도 메시지는 유효한 HTTP 메시지이므로 검증 결과를 무시하고 처리해 버리는 실수가 생기기 쉽다고 지적한다. 올바른 서명만으로 시험하면 이런 실수가 드러나지 않으므로, 잘못된 서명이 실제로 거부되는지도 시험해야 한다.

참고한 명세

모두 2026-10-20에 확인했다.

블로그로