PEM 파일과 x509 그리고 jwk

김성현·2022년 1월 6일
2
post-thumbnail

openSSL을 통해 TLS 서버 인증서를 만들때 PEM 형식으로 된 인증서를 내보내 본 적이 있다. 그래서 PEM이 뭔지 알고 있다고 생각하고 있었다.

그런데 최근데 JWT를 공부하면서 JWK 형태의 키를 사용하며 무척이나 혼란스러운 상황을 마주했다.

그 코드는 인터넷을 떠돌면서 우연히 본 코드라 정확한 내용은 기억이 안나서(어디서 본건지도 기억이 안난다... 최근 잠이 잘 안와서 좀 비몽사몽하다 ㅠ) 대략 기억을 더듬어 비슷하게 코드를 짜 봤다. 대략 설명하자면 해당 코드는 JWK를 PEM 형식으로 전환하고, PEM을 이용해 JWT를 검증하는 JS 코드였다.

const jwt = require(...)
const jwk2pem = require(...)

...
let token = {
  "iss" : "~~~",
  "sub" : "~~~",
}
...
let keyset = {
  "keys": [
    {
      // RS256 형식의 비밀키
    },
    {
      // ^ 위 키의 공개키
    }
  ]
}

jwt.sign(token, jwk2pem.convert(keyset))

대략 이런 느낌의 코드였다.

여기서 완전히 패닉이 왔다.

  • JWK는 뭔데 PEM으로 변환을 하는건데???
  • 아니 그보다 JWT 검증을 키가 PEM 형식이여도 되는 거였어?
  • 아니 그보다 PEM은 그럼 뭐지?

이런 질문이 생기고 나서 곰곰히 생각해 보니 나는 PEM이 무었인지 자세히 모른다는 생각이 들었다.
PEM 파일을 이용해 인증서를 만들고 그걸로 인증만 해 봤지 정작 그 내부동작은 신경쓰지도 않았다.

왜? 잘 동작하니까.

그런데 이번에 JWK와 PEM를 동시에 보면서 엄청난 혼란이 와 이번 기회에 두 파일 형식의 차이를 이해하고 어떻게 동작하는지를 분석하기 위해 지금부터 go 언어의 표준 라이브러리 코드(encoding/pem, crypto/x509, encoding/asn1)와 JWK 코드 구현체 소스코드를 읽어보기로 하였다.

분명 틀린 부분도 있겠지만 최대한 자세히 분석을 해 보려고 한다.

그러니까 이건, 내 삽질의 기록이다.

PEM 이란 무엇인가?

우선 간단한 코드를 작성했다. 코드는 다음과 같다.

package main

import (
	"crypto/x509"
	"encoding/pem"
	"fmt"
)

func main() {
	// 해당 PEM 파일은 아래 URL에서 왔습니다.
	// 네 구글 에서 제공하는 테스트 인증서 입니다.
	// https://github.com/google/certificate-transparency/blob/master/test/testdata/google-cert.pem
	data := `-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----`
	// PEM 블록입니다.
	block, _ := pem.Decode([]byte(data))
	fmt.Println(block.Type)
	// 블록 내용을 x509 인증서 형식으로 파싱합니다.
	cert, err := x509.ParseCertificate(block.Bytes)
	if err != nil {
		panic(err)
	}
	fmt.Println(cert.Subject)
	fmt.Println(cert.SignatureAlgorithm)
}

gist에서 보기, PEM 파일 파싱

해당 코드는 뭐 간단하다. pem.Decode 함수로 []byte형식의 데이터를 pem.block 단위로 나누는 함수를 써 pem.block을 얻고 해당 블록의 타입을 출력하고 블록 내부 데이터를 x509의 인증서 양식에 맞게 파싱하는 함수(x509.ParseCertificate)를 사용한다.
해당 코드의 실행 결과는 아래와 같다.

이로서 일반적으로 우리가 pem 이라고 부르는 녀석은 사실 pem과 x509의 조합임을 대충 눈치채게 되었다. 그래서 좀 더 자세한 코드를 살펴보게 되었다.

cs.opensource.google/go/src/encoding/pem/pem.go, Go언어 표준 pem 구현
해당 링크는 go 언어의 표준 pem.Decode 구현에 대한 줄이다.
코드 자체는 무척 간단했다.

// 일단 입력된 데이터를 다른 이름(rest)로 저장
// 이후 rest는 한 블록을 읽고 남은 블록들의 위치로 변함.
rest = data
// 시작이 `pemStart`로 시작하는지 확인.
// 즉 `-----BEGIN` 문자열로 시작하는지 확인하고 아니면 실패 디코딩 실패
if bytes.HasPrefix(data, pemStart[1:]) {
	rest = rest[len(pemStart)-1 : len(data)]
} else if i := bytes.Index(data, pemStart); i >= 0 {
	rest = rest[i+len(pemStart) : len(data)]
} else {
	return nil, data
}
// 만약 pem 파일 형태의 시작을 감지하면 해당 줄에서 시작 문자열인 
// `-----BEGIN`뒤이고 `-----`이전에 오는 단어를 추출함
typeLine, rest := getLine(rest)
if !bytes.HasSuffix(typeLine, pemEndOfLine) {
	return decodeError(data, rest)
}
typeLine = typeLine[0 : len(typeLine)-len(pemEndOfLine)]
 
// 리턴할 블록의 포인터를 만들고 일단 추출한 타입을 등록함.
p = &Block{
	Headers: make(map[string]string),
	Type:    string(typeLine),
}

for {
	// 해당 for문에서 pem 파일의 헤더를 추출하고 p.Headers에 등록함.
	...
}

// 여기서부터 pem 파일의 종료 지점을 파악함.
// 그러니까 `-----END $(p.Type)-----` 형태의 줄을 찾아냄
var endIndex, endTrailerIndex int
...
// PEM에서 헤더와 BEGIN, END를 제외한 본문은 바이너리 형식이 들어갈 수도 있어서 
// Base64 STDEncoding을 이용해 기록된다고 한다.
base64Data := removeSpacesAndTabs(rest[:endIndex])
p.Bytes = make([]byte, base64.StdEncoding.DecodedLen(len(base64Data)))
	n, err := base64.StdEncoding.Decode(p.Bytes, base64Data)
...
// 종료 줄을 찾으면 파싱하고 남는 바이트를 두번째 리턴값으로, 
// 첫번째는 `p`를 리턴한다.
return 

위의 링크 코드를 분석하면 대략 이런 형태인데 여기엔 그 어디에도 x509에 대한 내용이 없다. 이제서야 pem 파일을 어느정도 이해한 기분이 들었다.
그렇다, pem은 그저 json이나 xml 같이 데이터를 저장할 수 있는 양식일 뿐이였다.

그제서야 x509라는 이름과 pem이 내 머리속에서 분리가 되었다.

위키백과의 내용 중 , so that a PEM file may contain "almost anything base64 encoded and wrapped with BEGIN and END lines". 이 줄에 따르면 실제로도 PEM 블록 사이에는 무슨 값이든 들어갈 수 있다고 한다.

물론 pem이 인터넷 보안과 관련된 정보 저장 이외의 목적으로 사용된 사례는 없는 것 같다.

아! x509가 비밀키나 인증서, 공개키를 저장하는 방법이구나!

위에서 pem은 그저 데이터를 표현하는 방식임을 알게 되었으니 이제 x509가 어떻게 pem 형식으로 저장되는지 알아보도록 하기로 했다.

여기서 x509에서 정의되는 모든 것들을 이야기 해 볼수는 없으니 내가 아는 것들만 이야기 해보도록 한다.

과거에 AWS에서 x509 인증서 생성 및 서명이라는 글을 본 적 있다.
해당 글에서 각 단계에서 나오는 결과물이 각각 무슨 의미인지를 이해하지 못하고 그냥 따라만 했는데 이번에 직접 pem 형식의 x509에 정의된 이모저모를 뜯어보기로 하였다.

openssl genrsa <크기>

우선 명령어를 통해 rsa의 개인키를 2048 크기로 만들었다.

openssl genrsa 2048 > priv.pem

그러면 아래와 같은 파일이 나오는데 pem 파일은 바이너리 파일이 아니기에 그냥 텍스트 리더로 읽으면 읽을 수 있다. 내용은 다음과 같다.

-----BEGIN RSA PRIVATE KEY-----
<내용은 너무 길어서 생략함>
-----END RSA PRIVATE KEY-----

그럼 이제 이거도 한번 해석해 보자. 내부에 base64로 코딩된 부분도 위에 없지만 분석할거다.

우선 해당 파일은 결과물을 대충 보면 눈치채겠지만 PEM 형식으로 작성되었다.
그리고 RSA PRIVATE KEY가 PEM의 블록 타입이다.
이때 go 언어에서는 RSA PRIVATE KEY일때 써야하는 함수는 x509.ParsePKCS1PrivateKey이다.

이걸 어떻게 알았냐면 아래의 코드의 주석을 보면 PEM block의 타입이 RSA PRIVATE KEY일때 이 함수를 써야한다고 적혀있다.

그럼 본격적으로 go 언어 소스를 보고 해당 함수가 안의 내용을 어떻게 분석하는지 확인해 보자.

// 해당 구조체는 ASN1 형식으로 저장된 PEM 블록의 내용에 실제 저장된 타입이다.
// ASN1은 일종의 데이터를 바이너리 형식으로 내보내는 형식을 정의한 코드인데 
// 해당 내용은 구글에 검색하면 잘 나올 것이다.
type pkcs1PrivateKey struct {
	Version int
	N       *big.Int
	E       int
	D       *big.Int
	P       *big.Int
	Q       *big.Int
	// We ignore these values, if present, because rsa will calculate them.
	// 위의 주석을 참조하면 아래 3개의 값은 실제 규격상에는 존재하는 값이지만 
	// Go 언어에서는 해당 값들이 있어도 무시한다고 한다. 그 이유는 차후에
	// `rsa.PrivateKey.Precompute` 메서드를 통해서 계산되기 때문이다.
	Dp   *big.Int `asn1:"optional"`
	Dq   *big.Int `asn1:"optional"`
	Qinv *big.Int `asn1:"optional"`

	// 이건 무시하자.
	AdditionalPrimes []pkcs1AdditionalRSAPrime `asn1:"optional,omitempty"`
}

// ParsePKCS1PrivateKey parses an RSA private key in PKCS #1, ASN.1 DER form.
//
// This kind of key is commonly encoded in PEM blocks of type "RSA PRIVATE KEY".
// 이걸 보고 `RSA PRIVATE KEY`에는 이 함수를 사용해야 한다고 눈치챘다.
func ParsePKCS1PrivateKey(der []byte) (*rsa.PrivateKey, error) {
	// 위에서 이미 정의했다. asn1형식으로 포멧된 RSA 개인키를 불러온다.
	var priv pkcs1PrivateKey
	rest, err := asn1.Unmarshal(der, &priv)
	// 에러를 확인하는 부분인데 남는 바이너리 데이터가 있거나,
	// 다른 형식으로 추측되면 그에 맞는 에러 메시지를 띄운다.
	if len(rest) > 0 {
		return nil, asn1.SyntaxError{Msg: "trailing data"}
	}
	if err != nil {
		// EC(타원곡선) 형식으로 추측되거나,
		if _, err := asn1.Unmarshal(der, &ecPrivateKey{}); err == nil {
			return nil, errors.New("어쩌고 저쩌고 에러")
		}
		// PKCS8 형식으로 추측되면,
		if _, err := asn1.Unmarshal(der, &pkcs8{}); err == nil {
			return nil, errors.New("어쩌고 저쩌고 에러")
		}
		// 에러 뿜뿜
		return nil, err
	}
	//  go는 버전 1 이상은 지원 안하는 듯, 
	// 아니면 아직 1 이상의 버전이 없을 수도 있고
	// 해당 부분은 잘 모르겠다.
	if priv.Version > 1 {
		return nil, errors.New("")
	}
	// 개인키에서 N, D, P, Q는 모두 0 초과이여야만 한다. 그걸 확인하는 부분
	if N <= 0 || D <= 0 || P <= 0 || Q <= 0 {
		return nil, errors.New("N D P Q 모두 0 초과여야 한다고")
	}
	
	// 이제 읽어낸  값들을 go의 `rsa.PrivateKey` 형식으로 변환한다.
	key := new(rsa.PrivateKey)
	// ... 대충 priv 값들을 key로 옮기는 코드들 ...
    
	// 키가 정말로 올바른 값들인지 확인
	err = key.Validate()
	if err != nil {
		return nil, err
	}
	// CPU 소모적이고 반복적인 계산을 최소화하기 위해 일부 자주 사용되는 값들을 
	// 미리 계산해 놓음
	key.Precompute()

	return key, nil
}

이제 소스코드를 통해 x509, RSA PRIVATE KEY를 분석하고 어떻게 해석되는지를 알았다.
소스코드를 통해 RSA 형식의 개인키를 저장하는데 저장되는 포멧은 asn1이고 어떤 필드, 어떤 내용이 필요한지는 x509를 통해 정해진다는 사실을 알았다.

해당 코드는 cs.opensource.google go crypto/x509/pkcs1.go에서 볼 수 있다.

openssl req -new -x509 -key <개인키> -out <인증서> -days <유효일>

위의 명령어는 RSA 개인키를 통해 자가 서명 인증서를 생성하는 명령어다. 해당 명령어를 통해 생성된 인증서를 가지고 한번 실행하고 분석해보자.

우선 테스팅을 위한 소스코드는 아래와 같다.

package main

import (
	"crypto/x509"
	"encoding/pem"
	"fmt"
	"io/ioutil"
)

//go:generate openssl genrsa -out priv.pem 2048
//go:generate openssl req -new -x509 -key priv.pem -subj "/C=US/C=KR" -out cert.pem
func main() {
	bts, err := ioutil.ReadFile("cert.pem")
	if err != nil {
		panic(err)
	}
	pem, rest := pem.Decode(bts)
	if len(rest) != 0 {
		panic(rest)
	}

	fmt.Println(pem)
	cert, err := x509.ParseCertificate(pem.Bytes)
	if err != nil {
		panic(err)
	}
	fmt.Println(cert.Subject.Country)
}

정말 간단한 코드인데 go:generate를 통해 openssl로 자가 서명 인증서를 간편히 만들 수 있게 구성했다.

-subj "/C=US/C=KR"해당 부분을 수정하면 fmt.Println(cert.Subject.Country)해당 부분에서 출력되는 부분을 수정 가능하다.

만약 -subj "/C=US" 이면 fmt.Println의 결과는 [US]이고,
만약 -subj "/C=US/C=KR" 이면 fmt.Println의 결과는 [US KR]이고,
만약 -subj "/C=KR" 이면 fmt.Println의 결과는 [KR]이다.

사실 ParseCertifiate의 코드는 asn1을 이용해 직접 디코딩하는 방식이기에 소스코드가 불필요하게 길어서 많이 요약을 하려고 한다.

우선 ParseCertificate의 소스코드는 아래와 같다

// ParseCertificate parses a single certificate from the given ASN.1 DER data.
func ParseCertificate(der []byte) (*Certificate, error) {
	cert, err := parseCertificate(der)
	if err != nil {
		return nil, err
	}
	if len(der) != len(cert.Raw) {
		return nil, errors.New("x509: trailing data")
	}
	return cert, err
}

해당 코드는 cs.opensource.google go crypto/x509/parser.go에서 볼 수 있다.

진짜 별거 없으니 바로 parseCertificate 부분으로 넘어가자.

여기는 기존 소스코드가 길어 일부는 생략되었다. 또 여기 적은 주석은 다 내가 쓴거고 기존 주석은 삭제했다.

func parseCertificate(der []byte) (*Certificate, error) {
	// 저장할 위치
	cert := &Certificate{}
	
	// asn1으로 파싱하기 위해 준비
	input := cryptobyte.String(der)
    
	// 반드시 시작이 asn1.SEQUENCE 이여야 한다. 아니면 에러를 내뿜는다.
	if !input.ReadASN1Element(&input, cryptobyte_asn1.SEQUENCE){ ... }
	
	// 일단 맞는 것 같으면 Raw에 기존 원본을 넘겨 놓는다.
	cert.Raw = input
    
	...
    
	// TBS를 일단 먼저 읽는다.
	var tbs cryptobyte.String
	if !input.ReadASN1Element(&tbs, cryptobyte_asn1.SEQUENCE){ ... }
	cert.RawTBSCertificate = tbs
	if !tbs.ReadASN1(&tbs, cryptobyte_asn1.SEQUENCE) { ... }
    
	// 버전 정보를 읽는다.
	if !tbs.ReadOptionalASN1Integer(&cert.Version, cryptobyte_asn1.Tag(0).Constructed().ContextSpecific(), 0) { ... }
	
	// 잘못된 버전을 탐지하는 코드, 버전은 1~3중 하나여야 함
	// 정확하게는 원본 데이터는 0~2지만 실제 번호는 +1된 숫자
	...
    
	// 시리얼 넘버를 확인
	serial := new(big.Int)
	if !tbs.ReadASN1Integer(serial) { ... }
	cert.SerialNumber = serial

	// 알고리즘을 읽음
	// 여기서 sigAISeq와 outerSigAISeq는 반드시 같아야 함
	var sigAISeq cryptobyte.String
	if !tbs.ReadASN1(&sigAISeq, cryptobyte_asn1.SEQUENCE) { ... }
	var outerSigAISeq cryptobyte.String
	if !input.ReadASN1(&outerSigAISeq, cryptobyte_asn1.SEQUENCE) { ... }
	if !bytes.Equal(outerSigAISeq, sigAISeq) { ... }
    
	// parseAI의 리턴타입은 `pkix.AlgorithmIdentifier`
	sigAI, err := parseAI(sigAISeq)
	if err != nil {...}
	cert.SignatureAlgorithm = getSignatureAlgorithmFromAI(sigAI)

	// Issuer 분석
	var issuerSeq cryptobyte.String
	if !tbs.ReadASN1Element(&issuerSeq, cryptobyte_asn1.SEQUENCE) { ... }
	cert.RawIssuer = issuerSeq
	...

	// validity 분석
	var validity cryptobyte.String
	if !tbs.ReadASN1(&validity, cryptobyte_asn1.SEQUENCE) { ... }
	cert.NotBefore, cert.NotAfter, err = parseValidity(validity)
	if err != nil { ...	}

	// subject 분석
	var subjectSeq cryptobyte.String
	if !tbs.ReadASN1Element(&subjectSeq, cryptobyte_asn1.SEQUENCE) {
		return nil, errors.New("x509: malformed issuer")
	}
	cert.RawSubject = subjectSeq
	subjectRDNs, err := parseName(subjectSeq)
	if err != nil {...}
	cert.Subject.FillFromRDNSequence(subjectRDNs)

	// SPKI 분석, 
	var spki cryptobyte.String
	if !tbs.ReadASN1Element(&spki, cryptobyte_asn1.SEQUENCE) { ... }
	cert.RawSubjectPublicKeyInfo = spki
	if !spki.ReadASN1(&spki, cryptobyte_asn1.SEQUENCE) { ... }
    
	// SPKI 사용 알고리즘 읽기
	var pkAISeq cryptobyte.String
	if !spki.ReadASN1(&pkAISeq, cryptobyte_asn1.SEQUENCE) { ... }
	pkAI, err := parseAI(pkAISeq)
	if err != nil { ... }
	cert.PublicKeyAlgorithm = getPublicKeyAlgorithmFromOID(pkAI.Algorithm)
    
	// 시그니처를 남기는데 사용한 공개키 데이터들을 분석
	var spk asn1.BitString
	if !spki.ReadASN1BitString(&spk) { ... }
	// 알고리즘에 따라 실제 작업을 수행하기 위한 공개키 데이터들을 분석
	cert.PublicKey, err = parsePublicKey(cert.PublicKeyAlgorithm, &publicKeyInfo{
		Algorithm: pkAI,
		PublicKey: spk,
	})
	if err != nil { ... }

	// 여기는 버전 2 이상의 옵션들을  분석 
	if cert.Version > 1 { ... }

	// 인증서에 필요한 시그니처가 여기서 나온다.
	// 역시 제일 마지막에 있네
	var signature asn1.BitString
	if !input.ReadASN1BitString(&signature) { ... }
	// 이 부분은 잘 모르겠다. 우측 정렬?
	cert.Signature = signature.RightAlign()

	return cert, nil
}

해당 코드는 cs.opensource.google go crypto/x509/parser.go에서 볼 수 있다.

위에 코드에서 사실 중요한 부분은 SPKI 부분하고 Signature 부분으로 각각 인증서와 매칭되는 공개키, 그리고 개인키로 인증된 디지털 시그니처가 Signature에 존재할 것임을 알 수 있다.

사실 x509.Certificate의 사용처를 생각하면 직관적인데, 인증서는 인증기관에서 인증기관 자신의 개인키를 통해 사용자가 요구한 정보들을 기반으로 시그니처를 생성하고 해당 시그니처를 통해 사용자는 자기 자신이 인증되었음을 확인하므로 인증서에는 확인을 위한 공개키와 서명이 존재해야 할 것이므로 위와 같은 형태일 것임을 예상 가능했다.

사실 이 코드는 asn1.Unmarshal을 통한 구조체 분해를 사용하지 않으니 코드가 좀 길어서 귀찮을 뿐 사실상 어려운 내용은 없었다.

아마 구조체를 분해하는 방식을 사용하지 않는 이유는 x509.Certificate에는 Raw* 필드들에 원본 데이터들이 들어가야 하기 때문일 것이다.

만약 구조체 분해기능을 이용하면 원본 데이터를 중간에 추출할 수 있는 방법이 없기에 불가피하게 이렇게 만들었다고 여겨진다.

그럼 이제 JWT와 PEM(x509)을 쓸 수 있는 이유를 알겠다.

사실 JWK나 x509나 개인키, 공개키를 공유하기 위해 키를 인코딩하는 방법에 대한 방법일 뿐 결국 인코딩을 풀고 나면 결국 *rsa.PrivateKey, *rsa.PublicKey 같은 공개키 알고리즘에 사용되는 내용들을 저장하고 있다는 것이 동일하니 만약 키 정보가 같다면 JWK를 쓰나 PEM을 쓰나 동일한 것이나 다름없음을 알 수 있었다.

물론 x509는 인증서의 소스코드 내용에서 볼 수 있듯이 인증서 자체에 누가 인증서를 만들었는지, 누가 인증서를 사용하는지에 대한 정보같은 추가적인 필드가 매우 많았다.

하지만 JWK는 JSON 기반이라 필드들을 추가시키기 매우 쉬운 장점이 있으므로 인증기관이 JWK를 전격 도입한다면 사실상 큰 차이가 없어질 수 있을 것 같기도 하다. 물론 어떤 필드들을 사용할지에 대한 표준은 명확히 할 필요가 있겠지만.

개인적으로 이번에 인터넷 상에서 일어나는 인증에 대해서 더 깊은 이해를 할 수 있어서 좋은 경험이였던 것 같다.

또 언어 자체의 소스코드를 읽어볼 기회가 많지 않은데 이번에 뛰어난 개발자들의 소스코드를 읽어볼 수 있어서 좋았다.

앞으로 내가 나아가야 할 방향에 큰 도움이 된 좋은 경험이였다.

참조

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

0개의 댓글