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

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

1편 : 기존 라이브러리들과 그 비교.

JWT에 대하여.

최근 JWT를 공부하며 새로운 형식의 보안개념을 이해하고 이 개념이 실제로 널리 쓰이고 있다는 사실을 깨달았다.

또한 JWT를 공부하며 이 기술이 웹 보안에 있어서 큰 가능성을 가지고 있다고 확신하게 되었다.

그래서 JWT를 직접 써 보기로 하였다. 그런데 문제가 있었다. JWT는 JWT만으로는 별로 할 수 있는게 많지 않았다.

일단 JWT는 RFC 7519에 정의되어 있다. 그런데 JWT를 정의한 기관은 비슷한 시기에 다른 RFC도 몇개 써 놓았다. 이게 각각 JWS, JWE, JWK, JWA, JWT 이다.

각 RFC 문서는 아래를 참조하시라.

너무 자세하게 이야기하자면 길어지니, 간단히 이야기해보자.
JWT의 서명기능은 JWS(RFC 7515)에서 정의되고,
JWS에서 서명을 위해서는 암호를 위한 키가 필요한데 이 키를 정의하는 양식이 JWK(RFC 7517)이고, 사용하는 알고리즘에 대한 설명이 JWA(RFC7518)에서 정의되어 있다.

JWE는 잘 사용되지 않는다. 일단 지금도 충분히 이야기가 길어질 것 같으니 여기까지 이야기하자.

JWT를 직접 써 보자. Go 언어로.

나는 개인적으로 Go 언어를 선호한다.
그래서 Go 언어로 된 JWT 라이브러리를 찾아봤고 2개를 찾아냈는데 각각은 장단점이 있었다.
한 번 비교부터 해보자. 내가 찾은 라이브러리는 golang-jwt/jwtlestrrat-go/jwx이다. 그럼 이제 상세히 비교해보자.

2021-12-29일 기준 구글 검색시 dgrijalva/jwt-go가 최상단에 나오는데 해당 라이브러리는 현재 deprecated되었다. 라이브러리 owner였던 dgrijalvagolang-jwt 로 라이브러리 maintainer를 이관시켰다. 해당 내용은 Migrating Maintenance #462에 나와 있다.
실수로라도 쓰지 않게 주의해야 할 것 같다.

golang-jwt/jwt

해당 라이브러리는 go 언어에서 가장 널리 쓰이는 오픈소스 jwt 라이브러리일 것이다.
상대적으로 오래전부터 쓰였고(dgrijalva/jwt-go 시절까지 포함해서.), 상대적으로 간단한 사용법으로 이루어져 있다.

배우기 쉽고, 사용하기 쉽고, 너무 복잡하지 않게 이뤄져서 개인적으로 잘 작성된 오픈소스라고 생각되었다.

하지만 단점도 존재하는데 기본적인 JWT 인증과 데이터 핸들링에는 매우 편리하지만 JWK라 불리우는 JWK를 이용한 JWT 인증과 관련된 부분이 존재하지 않는다.

만약 JWK를 이용해서 JWT 검증을 하길 원할 경우 해당 라이브러리는 직접 JWK를 어떻게 go 언어의 키로 전환시키고 그걸 넘겨줘야 하는데 이 과정은 상상 이상으로 상당히 귀찮다.

즉 한 줄로 요약하면 편하고 쉬운데, 복잡한 기능은 모두 쳐내진 기본에 충실한 라이브러리이다.

lestrrat-go/jwx

해당 라이브러리는 5개의 서브모듈로 이뤄져 있다. 이름부터 jwt가 아닌 jwx인데 해당 라이브러리 제작자는
jwx/jws, jwx/jwe, jwx/jwk, jwx/jwa, jwx/jwt로 라이브러리를 5개의 서브모듈로 쪼재 놓았다.
또한 API는 극도로 정제되어 상당히 유용한 패턴이 눈에 띄는데 대표적으로 아래 패턴이 눈에 띄었다.

	token, err := jwt.Parse(
		payload,
		// Tell the parser that you want to use this keyset
		// 피서에게 해당 키셋을 이용한다고 알려준다.
		jwt.WithKeySet(keyset),
		// Uncomment the following option if you know your key does not have an "alg"
		// field (which is apparently the case for Azure tokens)
		// 만약 키에 `alg`필드가 없다면 라이브러리가 키의 알고리즘을 추측할 수 있도록 아래 줄의 주석을 풀어줘야 한다.
		// jwt.InferAlgorithmFromKey(true),
	)
	if err != nil {
		fmt.Printf("failed to parse payload: %s\n", err)
	}
	_ = token

해당 코드는 여기서 찾아볼 수 있다. jwt_example_test.go#L92-L103

Go는 기본적으로 정적 타입에 간결함을 지향하는 언어라 js 처럼 유연한 사용이 힘든데 위의 파서 함수는 Varidic Parameter를 통해 유연한 옵션값 추가를 주었다. 말로만 설명하면 힘드니 jwt.Parse함수의 정의를 직접 봐 보자.

// https://github.com/lestrrat-go/jwx/blob/de7d9bca43812abc4f630320f8632108ccfe34bf/jwt/jwt.go#L56-L78
func Parse(s []byte, options ...ParseOption) (Token, error) {
	return parseBytes(s, options...)
}

// https://github.com/lestrrat-go/jwx/blob/de7d9bca43812abc4f630320f8632108ccfe34bf/jwt/options.go#L42-L48
type ParseOption interface {
	ReadFileOption
	parseOption()
}

코드에서 보여지듯이 옵션을 여러개 원하는 만큼 자유롭게 넘길 수 있다.
자바로 치면 메소드 오버로딩에 가까운 형태의 함수 호출이 가능해 진다.
Go는 오버로딩이 불가능해 만약 매개변수가 선택적인 경우, nil값을 넘겨주는 형식으로 대응하는데 위와 같은 형태를 사용하면 코드가 좀더 정돈된 느낌을 주게 된다.

아마 오버헤드가 있을 것이다. 있을 수 밖에 없다. 하지만 코드가 간결하고 읽기 좋아지는 것이 더 중요하다 생각하기에 나는 위의 패턴도 좋다고 생각한다.

하지만 해당 라이브러리엔 내가 생각하기에 치명적인 문제가 있었다.

내 생각일 뿐이니 깊게 생각할 필요는 없다. 그저 내 생각일 뿐.

아래 코드를 봐 보자.

	// For demonstration purposes, we need to do some preparation
	// Create a JWK key to sign the token (and also give a KeyID)
	realKey, err := jwk.New(privKey)
	if err != nil {
		fmt.Printf("failed to create JWK: %s\n", err)
		return
	}
	realKey.Set(jwk.KeyIDKey, `mykey`)

	// Create the token
	token := jwt.New()
	token.Set(`foo`, `bar`)
	// Sign the token and generate a payload
	signed, err := jwt.Sign(token, jwa.RS256, realKey)
	if err != nil {
		fmt.Printf("failed to generate signed payload: %s\n", err)
		return
	}

해당 코드는 jwt_example_test.go#L40-L58에 있다.

별 문제가 없어 보이지만 여기서 한줄이 문제다.

	signed, err := jwt.Sign(token, jwa.RS256, realKey)

여기서 realKey는 jwk.Key 인터페이스인데, Key 인터페이스에는 이미 Algorithm이 정의되어 있을 수도 있다.
예를 들어 realKey에 이미 RS256 알고리즘이 정의되어 있다면 해당 코드는 이렇게 수정해야 한다.

	signed, err := jwt.Sign(token, realKey.Algorithm(), realKey)

또 키는 RSA가 아니라 ECDSA일 수도 있는데 그럼 Key에 알고리즘이 정의되지 않았더라도 RS256은 못쓸 것이다. 이런 경우 에러가 나겠지만 이런 식으로 사용자의 실수를 유발하기 좋은 매개변수가 존재하는 것은 좋지 못하다고 여겨진다.

또 짜증나는 점은 몇가지 더 있는데 아래 코드를 보자.

// https://github.com/lestrrat-go/jwx/blob/de7d9bca43812abc4f630320f8632108ccfe34bf/jwt/jwt.go#L493-L495
func Sign(t Token, alg jwa.SignatureAlgorithm, key interface{}, options ...SignOption) ([]byte, error) {
	return NewSerializer().Sign(alg, key, options...).Serialize(t)
}

// https://github.com/lestrrat-go/jwx/blob/de7d9bca43812abc4f630320f8632108ccfe34bf/jwk/interface_gen.go#L94
type Key interface {
	...
	Algorithm() string
	...
}

// https://github.com/lestrrat-go/jwx/blob/de7d9bca43812abc4f630320f8632108ccfe34bf/jwa/signature_gen.go#L14
type SignatureAlgorithm string

위의 코드에서 Key.Algorithm 메서드의 리턴 타입은 string 인데 정작 Sign 메서드가 받는 알고리즘 값은 jwa.SignatureAlgorithm 타입이다. 이게 문제가 되냐 하면 그건 아니다. 위의 코드를 보면
jwa.SignatureAlgorithm는 그저 string을 재정의한 타입이니 서로 100% 호환이 된다.
하지만 이렇게 타입을 분리시켜 놓으면 처음 쓰는 입장에서는 뭔가 문제가 생기지 않나? 하는 걱정이 든다.
특히 해당 라이브러리는 API를 어떤 측면에서는 무척이나 가다듬은 듯한 느낌을 주는데 어떤 부분은 별로 그렇지 못한 느낌을 줘서인지 더 거슬렸다.

이런 이유가 생긴 이유를 추측하면, *_gen.go 파일이 많은 것으로 보아 무언가의 수단으로 자동생성된 코드가 많은 모양인데 이 때문에 이런 부분이 생겼다고 생각된다.

어떤 API는 괜찮은데 어떤 API는 쓰기 굉장히 불편하고 신경써줘야 할 점이 많으니 불편하고 아직 사용하기엔 불편한 점이 많다고 여겨 해당 라이브러리는 사용하기 꺼려졌다.

즉 한줄로 요약하면 기능이 많고 편리한 부분이 있는데, 무언가 정돈되지 않은 느낌을 주는 라이브러리였다.

그럼 직접 만들어야지 뭐

https://www.googleapis.com/oauth2/v3/certs에 구글에서 oauth2에 사용되는 인증서의 JWK 버전이 있다.
나는 이걸 쓰길 원하는데 JWK는 lestrrat-go/jwx만 지원하고 해당 jwk를 golang-jwt/jwt에서 사용하기에는 좀 불편한 감이 없지 않았다.

그래서 나는 JWK라이브러리를 직접 만들어보기로 결심했다.

해당 프로젝트는 golang-jwt/jwt와 완전히 호환되는 것을 목표로 하며 API는 lestrrat-go/jwx의 영향을 많이 받았다.

그럼 지금부터 프로젝트를 계속 진행하며 일정 목표에 도달할 때 마다 블로그 글을 시리즈로 작성해 보겠다.

아참! Github 주소는 https://github.com/iamGreedy/jwk 이다.

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

0개의 댓글