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

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

3편 : RFC7518을 읽어보자!

우선 RFC7518에 대해 이야기하자면 RFC7518은 JWA에 대한 내용이다.
그런데 JWA는 기존에 있던 것들을 JSON 형태로 바꾸는 방법론에 대한 내용이 적혀 있다.
예를 들어 RSA 암호는 인증을 위해 2개의 자료가 필요하다. 개인키에 대한 정보와 공개키에 대한 정보가 그것이다.

좀 더 구체적으로 얘기하면 RSA 공개키는 N, E 라는 정수가 필요하고 RSA의 비밀키는 N, E, D, P, Q라는 정수가 필요하다.(다중 소수 가 아닌 경우, Dp, Dq, Qi는 유도값이므로 생략)

하지만 해당 RSA 암호의 정수는 크기 제약이 없어야 한다.(64bit 이상의 정수를 다뤄야 하는 경우도 있다.) 그런데 언어별로, 라이브러리별로 이런 크기 제약 없는 정수(앞으로 bigint라고 부르겠다.)를 정의하는 방법은 다 다르다. 64비트 이하의 정수들과는 다르게 bigint는 마땅히 공식적으로 정의된 방법이 없다.

구현법은 유사하지만 공식적으로 이렇게 해라! 하는 내용은 없는 걸로 알고 있다.

따라서 JWA는 이런 값들을 다른 언어들과 공유할때 어떤식으로 값들을 공유할지에 대한 내용이다.
JWA는 다음과 같은 암호화에서 필요한 값들과 해당 값을 어떻게 인코딩 해야 하는지에 대해 다룬다.

JWA에서는 JWS, JWE, JWK에서 쓰이는 알고리즘들이 등록되어 있는데 항상 얘기하다시피 JWE에 관한 내용은 빼고 갈 것이다.

JWA에서 정의한 JWS를 위한 알고리즘

  • HS256 : HMAC using SHA-256
  • HS384 : HMAC using SHA-384
  • HS512 : HMAC using SHA-512
  • RS256 : RSASSA-PKCS1-v1_5 using SHA-256
  • RS384 : RSASSA-PKCS1-v1_5 using SHA-384
  • RS512 : RSASSA-PKCS1-v1_5 using SHA-512
  • ES256 : ECDSA using P-256 and SHA-256
  • ES384 : ECDSA using P-384 and SHA-384
  • ES512 : ECDSA using P-521 and SHA-512
  • PS256 : RSASSA-PSS using SHA-256 and MGF1 with SHA-256
  • PS384 : RSASSA-PSS using SHA-384 and MGF1 with SHA-384
  • PS512 : RSASSA-PSS using SHA-512 and MGF1 with SHA-512
  • none : no digital signature or MAC performed

해당 값들은 JWS에 대해 정리한 이전 글에서 JOSE Header에서 alg필드에 들어갈 수 있는 값들의 목록이다.

위를 보면 대다수의 모든 알고리즘에서 SHA를 이용한 해싱을 사용하는데 이는 출력의 크기를 고정시키기 위한 목적인 것 같다.

위 알고리즘을 보면 알고리즘 이름은 두 부분으로 나누어져 있는데 앞의 1글자로 된 알고리즘 이름, 그리고 나머지 4글자로 된 해시 함수이다.

HS256Hmac Sha256의 약자고 ES256Ecdsa Sha256의 약자이다.

그런데 사실 SHA를 통한 해싱 파트는 덜 중요하고 앞의 MAC 혹은 디지털 서명 부분이 중요한데 이 부분을 나눠 아래에서 어떻게 작업해야 하는지 알아보겠다.

none은 말 그대로 인증 x까! 라는 의미이다. 즉 JWS를 안쓴다는 의미인데... 사실 의미가 있으려나 싶다. 만약 none을 쓴다면 JWS는 그저 base64url 인코딩된 json에서 헤더가 추가된 것이니 none을 쓸 바에야 그냥 JWS를 아예 안쓰고 마는게 나을 듯 싶다.

HS256 / HS384 / HS512

일단 기본적으로 HMAC이라는 건 해시 함수를 이용해 메시지 인증 코드를 만드는 것을 의미한다.
이때 HMAC에서는 해시 함수를 선택 가능한데 이를 SHA-256을 쓰면 HS256이고 SHA-384를 쓰면 HS384가 되는 식이다.
의사코드는 시시하니 파이썬으로 HS 시리즈가 어떻게 계산되는지 짜 보면 대략 이런 형태다.

import hashlib
import hmac

def HS256(password, input):
    return hmac.new(password, input, hashlib.sha256).digest()
    
def HS384(password, input):
    return hmac.new(password, input, hashlib.sha384).digest()
    
def HS512(password, input):
    return hmac.new(password, input, hashlib.sha512).digest()

진짜 별거 없다...
특히 HMAC은 대칭키라서 암호화 복호화에 필요한 패스워드도 동일하기에 특히 더더욱 신경쓸 점이 적다.

RS256 / RS384 / RS512

RS 시리즈는 암호를 위해 RSA 암호화를 이용하는 디지털 인증 방법이다.
여기서부터는 살짝 골치아픈 부분이 생기는데 디지털 인증은 비대칭 암호화를 이용하고 비대칭 암호는 암호화할 때와 복호화 할 때 필요한 암호가 다르다.
그 외에도 이녀석의 풀네임은 RSA SHA-xxx가 아니라 RSASSA-PKCS1-v1_5-SIGN SHA-256라고 한다.
아니 RSA면 다 같은 RSA아닌가 했는데 사실 RSA에는 PKCS1 v1.5말고도 수많은 바리에이션이 있다고 한다. 사실 이건 나도 처음 알아서 뭐라고 말할 수 없는데 이 것들을 나중에 별도로 공부해 정리해 봐야겠다.
아무튼 이 암호화를 파이썬 코드로 나타내면 다음과 같다.

from Cryptodome.Signature import PKCS1_v1_5
from Cryptodome.Hash import SHA256, SHA384, SHA512

def RS256Sign(privateKey, input):
    return PKCS1_v1_5.new(privateKey).sign(SHA256.new(input))

def RS256Verify(publicKey, input):
    return PKCS1_v1_5.new(publicKey).verify(SHA256.new(input))
    
def RS384Sign(privateKey, input):
    return PKCS1_v1_5.new(privateKey).sign(SHA384.new(input))

def RS384Verify(publicKey, input):
    return PKCS1_v1_5.new(publicKey).verify(SHA384.new(input))
    
def RS512Sign(privateKey, input):
    return PKCS1_v1_5.new(privateKey).sign(SHA512.new(input))

def RS512Verify(publicKey, input):
    return PKCS1_v1_5.new(publicKey).verify(SHA512.new(input))

PyCryptodome은 기본 패키지가 아니다. 파이썬은 rsa를 기본 라이브러리에 포함시키지 않아 외부 라이브러리를 써야 하는데 그중 가장 널리 알려진 것이 PyCryptodome이다.

ES256 / ES384 / ES512

ECDSA는 뭔가 이름만 들어도 멋진 타원 곡선 암호라는 녀석을 이용한다.
ES256ECDSA using P-256 and SHA-256의 약자인데 말 그대로 SHA-256과 P-256 타원을 이용한 타원곡선 암호의 약자이다.

ECDSA에서 P-512 는 없다. P-521이 있다. 가끔 헷갈린다. 512가 없는 이유는 정말 복잡해서 좀 공부할 필요성이 있다고 생각된다.

이녀석도 파이썬을 이용해 짜 보자.

from Cryptodome.Signature import DSS
from Cryptodome.Hash import SHA256, SHA384, SHA512

def ES256Sign(privateKey, input):
    return DSS.new(privateKey, 'fips-186-3').sign(SHA256.new(input))

def ES256Verify(publicKey, input):
    return DSS.new(publicKey, 'fips-186-3').verify(SHA256.new(input))

솔직히... 슬슬 귀찮아져서 숫자 장난만 친 부부은 넘겼다. ES256만 보면 ES512도 어떻게 될지 뻔하니...

PS256 / PS384 / PS512

해당 인증은 RSASSA-PSS 라는 녀석을 쓴다는데 솔직하게 나는 해당 암호화는 잘 모른다. 사실 처음 들어봤다. 그러니 차후 공부하면서 글을 올려보기로 하자.

none

뭐 이건 설명할 필요가 없을 것 같다. 인증을 안할 때 해당 타입을 사용한다.

RS256하고 ES256을 이용한 인증 구현

그러면 지난 JWS 포스팅에서 RS256하고 ES256을 사용해 보겠다고 했는데 지금이 바로 그때다. 페이로드와 JOSE 헤더alg필드만 각각 RS256하고 ES256인걸 제외하면 모두 같다. 한번 진행해 보자.

RS256을 이용한 JWS

우선 jwt.io를 이용해서 테스트를 할건데 해당 사이트에서 기본으로 설정된 키가 있다. 나는 편의상 개인키를 해당 사이트에서 제공하는 키를 이용하기로 하였다.
그럼 파이썬 코드를 이용해 인증된 JWS를 만들어 보자.

# RSA를 위한 개인키.
x509prikey = """-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC7VJTUt9Us8cKj
MzEfYyjiWA4R4/M2bS1GB4t7NXp98C3SC6dVMvDuictGeurT8jNbvJZHtCSuYEvu
NMoSfm76oqFvAp8Gy0iz5sxjZmSnXyCdPEovGhLa0VzMaQ8s+CLOyS56YyCFGeJZ
qgtzJ6GR3eqoYSW9b9UMvkBpZODSctWSNGj3P7jRFDO5VoTwCQAWbFnOjDfH5Ulg
p2PKSQnSJP3AJLQNFNe7br1XbrhV//eO+t51mIpGSDCUv3E0DDFcWDTH9cXDTTlR
ZVEiR2BwpZOOkE/Z0/BVnhZYL71oZV34bKfWjQIt6V/isSMahdsAASACp4ZTGtwi
VuNd9tybAgMBAAECggEBAKTmjaS6tkK8BlPXClTQ2vpz/N6uxDeS35mXpqasqskV
laAidgg/sWqpjXDbXr93otIMLlWsM+X0CqMDgSXKejLS2jx4GDjI1ZTXg++0AMJ8
sJ74pWzVDOfmCEQ/7wXs3+cbnXhKriO8Z036q92Qc1+N87SI38nkGa0ABH9CN83H
mQqt4fB7UdHzuIRe/me2PGhIq5ZBzj6h3BpoPGzEP+x3l9YmK8t/1cN0pqI+dQwY
dgfGjackLu/2qH80MCF7IyQaseZUOJyKrCLtSD/Iixv/hzDEUPfOCjFDgTpzf3cw
ta8+oE4wHCo1iI1/4TlPkwmXx4qSXtmw4aQPz7IDQvECgYEA8KNThCO2gsC2I9PQ
DM/8Cw0O983WCDY+oi+7JPiNAJwv5DYBqEZB1QYdj06YD16XlC/HAZMsMku1na2T
N0driwenQQWzoev3g2S7gRDoS/FCJSI3jJ+kjgtaA7Qmzlgk1TxODN+G1H91HW7t
0l7VnL27IWyYo2qRRK3jzxqUiPUCgYEAx0oQs2reBQGMVZnApD1jeq7n4MvNLcPv
t8b/eU9iUv6Y4Mj0Suo/AU8lYZXm8ubbqAlwz2VSVunD2tOplHyMUrtCtObAfVDU
AhCndKaA9gApgfb3xw1IKbuQ1u4IF1FJl3VtumfQn//LiH1B3rXhcdyo3/vIttEk
48RakUKClU8CgYEAzV7W3COOlDDcQd935DdtKBFRAPRPAlspQUnzMi5eSHMD/ISL
DY5IiQHbIH83D4bvXq0X7qQoSBSNP7Dvv3HYuqMhf0DaegrlBuJllFVVq9qPVRnK
xt1Il2HgxOBvbhOT+9in1BzA+YJ99UzC85O0Qz06A+CmtHEy4aZ2kj5hHjECgYEA
mNS4+A8Fkss8Js1RieK2LniBxMgmYml3pfVLKGnzmng7H2+cwPLhPIzIuwytXywh
2bzbsYEfYx3EoEVgMEpPhoarQnYPukrJO4gwE2o5Te6T5mJSZGlQJQj9q4ZB2Dfz
et6INsK0oG8XVGXSpQvQh3RUYekCZQkBBFcpqWpbIEsCgYAnM3DQf3FJoSnXaMhr
VBIovic5l0xFkEHskAjFTevO86Fsz1C2aSeRKSqGFoOQ0tmJzBEs1R6KqnHInicD
TQrKhArgLXX4v3CddjfTRJkFWDbE/CkvKZNOrcf1nhaGCPspRJj2KUkj1Fhl9Cnc
dn/RsYEONbwQSjIfMPkvxF+8HQ==
-----END PRIVATE KEY-----"""
# 위 x509 PKCS#8 형식의 키를 pycryptodome에서 사용 가능한 형식으로 전환
prikey = RSA.import_key(x509prikey)

# 페이로드는 전과 같음.
JWSPayload = """{
	"iss":"joe",
	"exp":1300819380,
	"http://example.com/is_root":true
}"""

# 헤더에서 'alg' 값만 RS256 변경
JWSProtectedHeader = """{
    "typ":"JWT",
    "alg":"RS256"
}"""

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
# RS256Sing 함수는 위의 내용을 참조.
JWSSignature = base64.urlsafe_b64encode(
    RS256Sign(prikey, JWSSigningInput)).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는 다음과 같다.

ewogICAgInR5cCI6IkpXVCIsCiAgICAiYWxnIjoiUlMyNTYiCn0.
ewoJImlzcyI6ImpvZSIsCgkiZXhwIjoxMzAwODE5MzgwLAoJImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlCn0.
CkpTr8irovfQ6CS1Fbg162HkfGqdDNUxuJYSiAusUy6JfxfDUZIyjDMDqKaeChu6A6tOy7o8Ww51Oq5bLYr3Kt3lJpFXqDBDKh5w4YT2iqf7wWuS0ddjGo4iUvPBzTKwuVC_7WSd3EPh61eZCF7oO0e8ILp2wzqNCaA4l_mzV5IeNi08IPIsGYWSuhzggzbNNJlYfzYYNa6qIBfx0BmOc4KRBHdD8r-2kOtTU2HynX4vYoVY7R2vUWoxTuK62yQ2s3oacfAXUKrWYf5pKv6-JJMHUc_tsFQKwS3_injdd9lGIX2JJjmOGSSVh52lAhGyQ7OB_6DDT1psYDsDJ1UGfA

보기 좋으라고 줄바꿈을 넣었다. 원래는 줄바꿈 없이 연결된 형태다.

이 JWS를 가지고 jwt.io에서 테스트를 해보자.

보다시피 잘 검증되었다. 해당 JWT를 테스트하려면 해당 링크를 누르면 된다.

ES256을 이용한 JWS

ES256RS256하고 거의 비슷하게 사용 가능하다. 바로 코드로 넘어가자.

# x509 PKCS#8 형식의 ECDSA를 위한 개인키
x509prikey = """-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2
OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r
1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G
-----END PRIVATE KEY-----"""
# 개인키를 pycryptodome에서 사용 가능한 형태로 불러들임
prikey = ECC.import_key(x509prikey)

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

# 'alg' 필드만 'ES256'으로 수정
JWSProtectedHeader = """{
    "typ":"JWT",
    "alg":"ES256"
}"""
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(
    ES256Sign(prikey, JWSSigningInput)).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는 아래와 같다.

ewogICAgInR5cCI6IkpXVCIsCiAgICAiYWxnIjoiRVMyNTYiCn0.
ewoJImlzcyI6ImpvZSIsCgkiZXhwIjoxMzAwODE5MzgwLAoJImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlCn0.
vB3WC-o-J-7I3f9_6nCMmvaTSHNgOAEHUskFEv4N2DTLxMY6VH6mme61O6NflnSepqJKkvaDcBKc-285HaZs7w

언제나. JWS는 줄바꿈이 없다. 그냥 보기 좋으라고 넣은거다.

해당 JWS를 검증해 보자.

예상대로 잘 검증 되었다. 해당 JWS는 여기서 확인할 수 있다.

JWA에서 정의한 JWK를 위한 값들

이제 JWS이야기를 드디어 끝내고 JWK에 대해 이야기하자. 사실 좀 너무 돌아온 감이 없잔으니 빨리 가자.
JWK는 HMAC, 혹은 디지털 서명을 위한 키를 저장하는 방법에 대한 표준이다.

하지만 암호에서 RSA 개인키에서 필요한 값들과 ECDSA 개인키에서 필요로 하는 값들은 서로 다를 것이다.

이런 식으로 키마다 다른 필요값들이 어떻게 저장되는지가 JWA에 정의되어 있다.

JWK에 이걸 정의해도 문제없을 것 같은데 이게 JWA에 정의되어 있더라.

그럼 이제부터 각 키에 어떤 값들이 필요한지 확인해 보자.

ECDSA를 위한 패러미터

해당 내용은 RFC7518의 6.2 챕터에 있다.

ECDSA 공개키

ECDSA 개인키에는 crv, x, y 값이 필요하다.
crvcurve의 약자로 다음 값 중 하나여야 한다.

  • P-256
  • P-384
  • P-521
    x, y는 bigint를 바이트 형식으로 변환한 것을 base64 url safe 인코딩 한 것이다.

ECDSA 개인키

ECDSA 개인키에는 ECDSA 공개키값들을 모두 가지고 한가지 값을 추가로 가진다.
d 필드로 해당 필드는 xy와 동일하게 인코딩된 bigint 값이다.

RSA를 위한 패러미터

해당 내용은 RFC7518의 6.3 챕터에 있다.

RSA 공개키

RSA 공개키에는 해당 값들이 필요하다. n, e
해당 값들은 모두 bigint를 바이트 형식으로 변환해 base64 url safe인코딩 한 것이다.

RSA 개인키

RSA 개인키에는 RSA 공개키에 포함된 값을 모두 가지고 추가로 d, p, q, dq, dp, qi
필드를 필수로 가지며, 만약 Multi-Prime RSA인 경우 추가로 oth필드가 존재할 수 있다.

다만 dq, dp, qi는 사실 없어도 n, e, d, p, q에서 유도가 가능한 값이다. 그냥 불필요한 중복연산을 피하기 위해 저장된 값이라 파싱 도중 해당 필드를 무시하고 재연산을 거쳐도 된다.

해당 값들은 oth 필드를 제외하면 모두 bigint를 바이트 형식으로 변환해 base64 url safe인코딩 한 것이다.

oth필드는 배열인데 배열 안에는 오브젝트가 들어가고 해당 오브젝트에는 r, d, t값이 들어간다.
r, d, t는 모두 base64 url safe인코딩된 bigint 이다.

대칭키를 위한 패러미터

해당 내용은 RFC7518의 6.4 챕터에 있다.

대칭키는 HMAC 같은 비대칭키를 사용하지 않는 경우 사용되는 필드 값이다.
여기서는 k 필드만 정의되면 되고 해당 필드는 base64 url safe인코딩된 바이트 배열이다.
얘는 bigint가 아니다.


이렇게 JWA 에 대한 내용도 모두 독파했다.

이제 대망의 JWK에 대한 RFC로 넘어가자.

사실 이 블로그글은 처음부터 JWK 오픈소스 라이브러리 만들기에 대한 글이였다.

어쩌다 이렇게 일이 커졌는지는 나도 모르겠다.

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

0개의 댓글