JWK 라이브러리를 만들어 보자! #2

김성현·2021년 12월 30일
0
post-thumbnail

2편 : RFC7515를 일단 읽어보자.

JWK 라이브러리를 만드려면 일단 JWK에 대해 이해할 필요가 있다.
그런데 JWK를 그냥 쓸 수는 없지 않은가? 그러니 일단 RFC 문서를 읽어보는 시간을 가지기로 했다.

우선 첫번째로 정리할 RFC는 RFC7515이다.

사실상 해당 글은 RFC7515를 요약하고 정리한 것에 내가 이해한 내용이 첨가된 번역글이나 다름없다.

RFC7516(JWE)는 읽지 않을것이다. 해당 내용은 내가 원하는 JWK를 구현하는데 별 도움이 되지 않는다.

RFC7159(JSON)은 이미 읽은 적이 있고 다들 아는 내용일테니 넘어가자.

RFC2119(RFCs Requirement Levels)도 알것 같으니 생략한다.

해당 글의 인트로에 따르면 JWS는 JSON 기반으로 디지털 서명이나, MAC(Message Authentication Code)에 사용할 수 있게 만들어진 것이라고 한다.

해당 RFC의 2번 챕터에 용어들이 정의되어 있는데 일단 자세하게 들어가기 전에 용어부터 정의하고 들어가자.

용어 정의

  • JSON Web Signature (JWS)
    디지털 서명, 혹은 MAC 된 메시지를 나타내는 데이터 구조.
  • JOSE Header
    암호화에 대한 내용이 정의된 JSON 구조 형태의 헤더.
  • JWS Payload
    보안이 지켜져야하는 옥텟 시퀀스, 한마디로 메시지
  • JWS Signature
    JWS Protected HeaderJWS Payload를 포함하는 데이터의 디지털 서명, 혹은 MAC 정보
  • Header Parameter
    JOSE Header에 포함되는 K/V 요소
  • JWS Protected Header
    디지털 서명, 혹은 MAC으로 무결성 보호된 Header Parameters가 저장된 JSON 객체.
    JWS Compact Serialization를 위해 전체 JOSE Header에 적용되어야 한다.
    만약 JWS JSON Serialization에서는 JOSE Header의 일부만 포함된다.
  • JWS Unprotected Header
    무결성 보호가 되지 않은 Header Parameters
    JWS JSON Serialization에서만 존재 가능하다.
  • Base64url Encoding
    설명이 필요한가? 단 주의할 사항으로는 URL Encoding을 쓰기는 한데 NoPadding을 사용한다.
  • JWS Signing Input
    디지털 서명 또는 MAC 계산에 필요한 입력, 해당 값은
    ASCII(BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload))
    위 과정을 통해 계산된다.
  • JWS Compact Serialization
    URL safe 한 base64로 인코딩된 JWS
  • JWS JSON Serialization
    JWS를 JSON으로 표현한 것. JWS Compact Serialization하고는 다르게 JWS JSON Serialization는 다중 디지털 서명과 MAC이 한 컨텍스트에 적용 가능하다. 이 표현은 URL safe 하지 않고 압축되지도 않았다.
  • Unsecured JWS
    JWS가 무결성을 보장하지 않는다. 이 Unsecured JWSalg 필드가 "none"이다.
  • Collision-Resistant Name
    같은 네임스페이스 내에서 이름은 어지간해서는 충돌하지 않게 해야 한다.
  • StringOrURI
    임의의 string에서 : 문자가 포함된 경우에 반드시 URI를 만족해야 한다.

JWS, JWS JSON Serialization, JWS Compact Serialization

JWS 에 대해서

JWS는 디지털 서명 혹은 MAC을 JSON 형태로 저장한 후 이를 URL safe한 base64로 인코딩된 데이터를 의미한다고 한다. 여기서 해당 JSON은 공백을 가질 수 있다고 한다.

JWS에는 아래와 같은 데이터가 들어간다.

  • JOSE Header
    • JWS Protected Header
    • JWS Unprotected Header
  • JWS Payload
  • JWS Signature

이렇게 정의된 JWS는 JSON 형태로 직렬화할 수도 있고 URL safe한 형태의 compact한 형태로 직렬화할 수도 있다.

JWS Compact Serialization

JWS Compact Serialization에선 우선 JWS Unprotected Header는 사용 불가능하다.
즉 무조건 무결성 보장된 헤더만 사용 가능하다.
JWS Compact Serialization을 쓰는 경우에는 JOSE HeaderJWS Protected Header랑 동일하다.

JWS Compact Serialization은 이렇게 구성된다.

BASE64URL(UTF8(JWS Protected Header)) || '.' ||
BASE64URL(JWS Payload) || '.' ||
BASE64URL(JWS Signature)

여기서 ||logical or가 아니라 string concatenation이다.
위의 형태를 JWT를 써본 분들이라면 한번에 이해할 수 있을 것이다.

https://jwt.io/ 에서 볼 수 있는 JWT 예시 데이터

위의 그림과 정의가 유사하지 않은가? 그렇다. 우리가 사용하는 JWT는 사실 JWS로 정의된 내용이였다.
JWT는 저기서 Payload에 들어가는 부분에 해당하는데 편의상 모두를 통칭해서 JWT라고 부르는 것이다.

JWT가 마치 모두를 통칭하는 것처럼 널리 알려져서 공부할때 용어때문에 많이 힘들었다.
이제와서 용어를 명확히 쓰는 것은 불가능하겠지만...

JWS JSON Serialization

JWS JSON Serialization은 JSON 데이터인데 여기서 아래 필드들을 허용한다.

  • payload
    해당 필드는 반드시 필요하다.
    해당 필드에는 전달할 데이터가 들어간다. 해당 필드에 들어갈 데이터는 다음과 같다.
    BASE64URL(JWS Payload)
  • signatures
    해당 필드는 반드시 필요하다.
    여기에 올 데이터는 배열으로 배열의 각 요소는 오브젝트로 해당 오브젝트에서는 아래와 같은 필드가 존재해야 한다.
    • protected
      해당 필드에는 무결성 보장된 헤더값만 들어갈 수 있다.
      해달 필드에 들어갈 실제 값은 아래 식으로 계산된다.
      BASE64URL(UTF8(JWS Protected Header))
      만약 무결성 보장된 헤더가 없다면 해당 필드는 반드시 없어야만 한다.
    • header
      해당 필드에는 무결성이 보장되지 않는 헤더가 들어간다.
      만약 무결성 보장된 헤더가 없다면 해당 필드는 반드시 없어야만 한다.
    • signature
      해당 필드는 반드시 필요하다.
      해당 필드는 다음을 통해 계산된다.
      BASE64URL(JWS Signature)

즉 위의 내용들을 정리하면 아래와 같은 예시가 나온다.

 {
	"payload": "eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ",
	"signatures":[
		{
			"protected":"eyJhbGciOiJSUzI1NiJ9",
			"header": {"kid":"2010-12-29"},
			"signature": "시그니처 데이터, 실제 데이터는 원본을 참조해라. 매우 길다."
		},
		{
			"protected":"eyJhbGciOiJFUzI1NiJ9",
			"header":{"kid":"e9bc097a-ce51-4036-9562-d2ade882db0d"},
			"signature":"마찬가지로 엄청 긴 데이터"
		}
	]
}

즉 JSON 형태에서는 한 페이로드로 여러개의 시그니처를 만들 수도 있다.

JWT를 JSON 형태로 만드는 것은 사용사례로 본 적이 없다. 하지만 이런 식으로 가능하다는 것이 매우 신기했다.

무척이나 흥미롭지만 이번 도전에는 별로 관계 없는 내용이다. 넘어가자.

실제 JWS 과정

이제 JWS 를 만드는 법을 알았으니 실제 어떤 과정을 거쳐 JWS가 만들어지는지 확인하자.
같은 페이로드를 대상으로 2가지 방법, JWS Compact Serialization, JWS JSON Serialization으로 해 볼 것이다.

페이로드를 계산하는 과정은 다음과 같다. 우선 페이로드가 아래와 같다고 하자.

{
	"iss":"joe",
	"exp":1300819380,
	"http://example.com/is_root":true
}

위 데이터를 utf8을 통해 바이트로 변환한 후 이를 base64 url safe를 통해 인코딩하면 데이터는 다음과 같다.

ewoJImlzcyI6ImpvZSIsCgkiZXhwIjoxMzAwODE5MzgwLAoJImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlCn0=

다만 맨 뒤의 = 은 패딩인데 패딩은 쓰지 않는 것 같다. 그러므로 패딩을 제거하면

ewoJImlzcyI6ImpvZSIsCgkiZXhwIjoxMzAwODE5MzgwLAoJImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlCn0

위 페이로드를 구하는 방식을 python으로 구현하면 아래와 같다.

payload = """{
	"iss":"joe",
	"exp":1300819380,
	"http://example.com/is_root":true
}"""
b64payload = base64.urlsafe_b64encode(bytes(payload, encoding='utf8')).replace(b'=', b'')

RFC에 나온 예시를 유사하게 따라가지만 위 내용은 내가 다시 계산한 내용이다.
RFC에 사례는 줄바꿈으로 CR LF를 사용하는데 내가 사용하는 환경에서는 LF만으로 줄바꿈을 수행하고 JSON 자체도 조금 달라서 인코딩 결과가 다르다. 이건 따라해보는 사람이 주의해야 할 것 같다.

JWS Compact Serialization 과정, HS256

디지털 서명, 혹은 HMAC을 남기는 알고리즘은 다양하지만 편의상 단 1개의 알고리즘 HS256만 사용해 보도록 하겠다.
HS256은 RFC 7518에 자세히 정리되어 있는데 HS256은 HMAC을 SHA-256을 이용해서 계산한다는 의미이다.

그럼 이제부터 HS256을 이용해 위 페이로드를 서명해 보자. 우선 서명을 하기 전에 JOSE Header 부터 작성해야 한다. 여기서 우리는 JWS Compact Serialization을 사용할 것이고, 해당 직렬화에는 반드시 JWS Protected Header 만 써야하니 이를 고려해서 헤더를 계산하자.

일단 헤더에 들어갈 내용은 아래와 같다.

{
	"typ":"JWT",
	"alg":"HS256"
}

내용은 간단하다.
페이로드의 타입은 JWT이고 알고리즘은 HS256을 사용한다.

많은 인터넷 블로그 글에서 typ에 대해 자세히 서술하지 않는 것 같아 이번에 RFC를 읽으면서 알게 된 사실을 공유한다.
typ에 들어가는 값은 사실 인터넷에서 널리 쓰이는 MIME 을 넣는다.
그러니까 만약 페이로드가 json 이라면 "typ" : "application/json" 형태여야 한다.
다만 RFC 7515 #4.1.9에 따르면 application/으로 시작하는 경우 해당 application/은 생략 가능하기에 "typ" : "json"이라고 표현이 가능하다 한다.
그러나 MIME에 추가적인 데이터가 있는 경우에는 요약이 불가능하다고 한다.
예를 들어 application/json;charset=utf-8json;charset=utf-8로 요약이 불가능하다고 한다.
그러니 JWT로 html을 보내는 사람은 아마 없겠지만 만약 보낸다면 헤더는 {"typ" : "text/html"}이 들어가야 한다.

한마디로, 우리가 JWT를 쓸때 헤더에 아무생각 없이 {"typ":"jwt"}를 넣는데 이게 사실 {"typ":"application/jwt"}의 약자였다는 것이다.

위 헤더를 Base64 url safe로 계산하면 값은 아래와 같다.

ewogICAgInR5cCI6IkpXVCIsCiAgICAiYWxnIjoiSFMyNTYiCn0

해당 값을 계산하는 python코드는 아래와 같다.


protectedHeader = """{
    "typ":"JWT",
    "alg":"HS256"
}"""
b64protectedHeader = base64.urlsafe_b64encode(bytes(protectedHeader, encoding='utf8')).replace(b'=', b'')

이제는 무결성을 보장해야 하는 부분을 전부 직렬화 했으니 HMAC만 계산하면 된다.
HMAC을 계산하는 의사코드는 아래 코드와 같다.

HMAC(
	"sha-256",
	"password"
	B64URLNoPad(protectedHeader) + "." + B64URLNoPad(payload), // 입력값
)

그럼 이걸 python 코드로 옮기고 jwt.io 에서 실제로 제대로 작성했는지 검증까지 해 보겠다.

우선 python 코드부터 작성하자.

import base64
import hashlib
import hmac

JWSPayload = """{
	"iss":"joe",
	"exp":1300819380,
	"http://example.com/is_root":true
}"""

JWSProtectedHeader = """{
    "typ":"JWT",
    "alg":"HS256"
}"""
Password = b'your-256-bit-secret'

b64JWSProtectedHeader = base64.urlsafe_b64encode(
    bytes(JWSProtectedHeader, encoding='utf8')).replace(b'=', b'')
b64JWSPayload = base64.urlsafe_b64encode(
    bytes(JWSPayload, encoding='utf8')).replace(b'=', b'')
# JWS 사인을 하기 위한 입력값
JWSSigningInput = b64JWSProtectedHeader + b'.' + b64JWSPayload
JWSSignature = base64.urlsafe_b64encode(hmac.new(
    Password, JWSSigningInput, hashlib.sha256).digest()).replace(b'=', b'')
# 보여주기용 print
print(f'{"BASE64URL(UTF8(JWS Protected Header))":30} : {b64JWSProtectedHeader}')
print(f'{"BASE64URL(UTF8(JWS Payload))":30} : {b64JWSPayload}')
print(f'{"JWSSigningInput":30} : {JWSSigningInput}')
print(f'{"JWSSignature":30} : {JWSSignature}')
# 최종 출력물
print("JWS : ", str(JWSSigningInput + b'.' + JWSSignature, 'ascii'))

위의 과정을 거치면 아래와 같은 JWS 로 사인된 메시지가 나온다 해당 값은 아래와 같다.

ewogICAgInR5cCI6IkpXVCIsCiAgICAiYWxnIjoiSFMyNTYiCn0.
ewoJImlzcyI6ImpvZSIsCgkiZXhwIjoxMzAwODE5MzgwLAoJImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlCn0.
xrQ4qxmw-7yeg7UK7BJfAdessCi-UTtZCNMN6NkLJgM

원래 JWS에는 줄바꿈이 없다. 그냥 단위별로 끊어서 보면 편해서 넣은 것 뿐이다.

그럼 이제 만들어진 값을 jwt.io에서 검증해 보자.

완벽하게 검증되었음을 볼 수 있다.

jwt.io에서는 비밀번호를 우측 아래에서 설정이 가능한데,
디코딩에는 커스텀 비밀번호를 사용할 수 없는 것 같다... 아니면 내가 잘 모르는 걸지도.
때문에 불가피하게 jwt.io에서 HS256에 기본으로 지정된 비밀번호인 your-256-bit-secret를 썼다.

HS256만 하는거냐? RS나 ES는 어디갔냐?

우선 미리 얘기하면 JWS는 인증과 검증을 위한 데이터를 저장하는 양식만 지정해 놓았다. 그렇기에 RSA라던가 ECDMA 에 필요한 정보는 JWA, RFC7518에 별도로 추가 정보가 있다.

실제로 JWT와 관련된 RFC를 읽어보면 인용 수준이 아니라 하나의 RFC라 불러도 될 정도로 RFC7515(JWS), RFC7516(JWE), RFC7517(JWK), RFC7518(JWA), RFC7519(JWT)는 관련이 깊다.

실제로 JWK에서 Key에서 필요한 필드들이 JWK에서 정의된게 아니라 JWA 문서를 읽으라고 리다이렉트를 걸어놓고, 하여간 이런게 수도 없이 많다.

그래서 여기서 HS256말고 RS나 ES 까지 하면 사실 JWA 내용을 빼먹기가 힘들다...

그래서 일단 실제 어떻게 인코딩되는지 예시만 작성해 보기 위해서 HS256만 작성했고 RS와 ES 는 나중에 하기로 했다.

사실 jwt.io에서 RS나 ES를 검증하려면 키 공유를 위해 JWK 혹은 x509로 된 키가 필요한데 이 부분은 JWK에서 이야기 할 필요가 있어서 그렇기도 하다.
하여간 한번에 다루기는 좀 불편하다.

우리는 대체 왜 JWS를 JWT라 부르기 시작했나?

진짜 궁금한게 우리가 대게 JWT라 부르는 녀석은 사실 JWS, 그 중에서도 JWS Compact SerializationJWS라는 것을 알았다.

그럼 도대체 왜 다들 이걸 JWT라고 하는건지... 솔직히 JWS 보다는 JWT가 어감이 더 멋지긴 한 것 같은데 진짜로 그건가?

참 모를 일이다.

profile
수준 높은 기술 포스트를 위해서 노력중...

0개의 댓글