openssl 없이 Go HTTPS 서버 실행해보기.

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

Go 언어로 https를 배운다면 누구나 아래 두 명령어를 써 본적 있을 것이다.
openssl genrsa 2048 > [저장할 개인키 파일명]
openssl req -new -x509 -key [저장된 개인키 파일명] -out cert.pem

대략 rsa 개인키를 2048 크기로 생성하고 해당 개인키로 자가 서명 인증서를 만드는 명령어이다.

그런데 내가 처음 해당 부분을 배웠을 때에는 openssl도 몰랐고 리눅스 환경(물론 윈도우에서도 쓸 수 있지만 대다수의 온라인 강좌는 리눅스 기반으로 설명한다.)에도 익숙하지 않았다.

그래서 처음 https를 공부할때는 다른 사람이 만든 자가 서명 인증서를 썼다.

이후 리눅스 환경에 익숙해지고 openssl 명령어가 각각 어떤 의미를 가지는지 알게 되었다.

이제는 시간이 지나 내부 내용을 이해하게 되자 openssl에서 수행하는 대다수의 기능이 Go 언어에서 자체적으로 지원함을 알게 되었다.(Go 뿐만이 아니라 대다수의 암호학을 표준 라이브러리로 지원하는 큰 언어에는 모두 가능하다, 물론 대다수의 경우 x509를 지원하지 않아 외부 라이브러리를 써야 하는 경우가 많았다.)

그럼 지금부터 openssl을 쓰지 않고 그때그때 실행할 때 마다 인증서를 만드는 방식으로 https go 서버를 만들도록 해 보겠다.

사실 해당 글은 JWT를 분석하면서 알게 된 x509를 다시 정리하며 알게 된 내용을 정리하는 글이다. JWT를 먼저 쓸려 했는데 JWT는 쓸 내용이 많아 해당 구현만 먼저 업로드하게 되었다.

일반적인 https 데모

아래는 go 언어를 써본 분이라면 누구나 본 적 있는 https 데모일 것이다.


package main

func main(){
	
	http.ListenAndServeTLS(
    		":https", 
    		"인증서 파일", 
		"개인키 파일", 
            	http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		rw.Write([]byte("Hello, World!"))
		rw.WriteHeader(http.StatusOK)
	}))
}

내용은 무척이나 쉬운데 TLS를 통해 암호화된 http 서버를 실행하는 코드이다.
":https"는 https의 기본 포트인 443포트로 ip나 도메인의 제약 없이 듣기 동작을 수행하고 "인증서 파일", "개인키 파일"를 참조해 https 암호화를 수행하는 것이다.
마지막 람다식은 https 요청을 받으면 무조건 200(Status OK)코드와 함께 Hello, World메시지를 보내는 코드이다.

그런데 여기서는 한가지 추가 작업이 필요한데 눈치빠른 분들은 이미 위에서 눈치채셨겠지만 인증서 파일과 개인키 파일은 미리 준비해야 한다는 것이다. 그리고 해당 파일들은 윗부분 글의 openssl 명령어로 만들수 있다.

그런데 최근에 x509를 공부하며 Go언어는 openssl에서 tls에 필요한 모든 부분이 이미 표준 라이브러리로 제공한다는 사실을 알았다.
그래서 위 코드를 openssl로 사전준비 없이 실행시킬 수 있게 코드를 수정해 보겠다.

우선 openssl genrsa를 Go기능으로 넘겨보자.

openssl genrsa 기능 대체

openssl genrsa 2048을 쉘에 입력하면 -----BEGIN RSA PRIVATE KEY-----로 시작하는 텍스트가 쉘에 출력된다. 해당 텍스트를 파일명.pem으로 저장하면 rsa 개인키가 파일에 저장되는 것이다.

그럼 이제 이걸 openssl 없이 한번 가보자.
Go 언어에서 rsa 개인키를 생성하는 함수는 rsa.GenerateKey이다.

그런데 rsa 개인키를 위해서는 D, N, E값이 필요하다.(물론 연산과정을 절약하기 위해서 Dp, Dq Qinv등의 값도 저장하기는 한다. 이 값들은 D, N, E로 유도 가능하다.)

그런데 이 값들을 tls에서는 바로 쓸 수 없다. x509에서 정의된 형태로 바꾸는 과정이 필요한데 이는 x509.MarshalPKCS1PrivateKey로 할 수 있다. 이 함수를 사용하면 개인키를 x509에서 이해할 수 있는 형태로 전환한다.

최종적으로 x509는 pem 블록 안에 넣어야 하는데 이를 pem.Block 구조체와 pem.EncodeToMemory함수로 행할 수 있다.

이를 모두 써서 openssl genrsa기능을 모두 대체하는 함수는 아래와 같다.

func mimicOpenSSLGenrsa() []byte {
	rawPkey, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		panic(err)
	}
	return pem.EncodeToMemory(&pem.Block{
		Type: "RSA PRIVATE KEY",
		Bytes: x509.MarshalPKCS1PrivateKey(rawPkey),
	})
}

참고로 pem.EncodeToMemory 함수는 편의성 함수로 pem.Encode로 바꿀 수 있다.

buf := bytes.NewBuffer(nil)
pem.Encode(buf, &pem.Block{
    Type: "RSA PRIVATE KEY",
    Bytes: x509.MarshalPKCS1PrivateKey(rawPkey),
})
return buf.Bytes()

실제로 Go 표준 라이브러리 코드를 읽어보면 아래와 같다.

func EncodeToMemory(b *Block) []byte {
	var buf bytes.Buffer
	if err := Encode(&buf, b); err != nil {
		return nil
	}
	return buf.Bytes()
}

해당 소스코드는 여기에 있다.

openssl req -new 대체

해당 기능은 개인키로부터 자가서명인증서를 제작하는 명령어이다.

이 기능은 대체하기 약간 귀찮은데 이 기능을 대체하는 핵심은 x509.Certificate 구조체와 x509.CreateCertificate함수이다.
해당 기능은 x509형식의 인증서를 pem의 내부에 넣기 위한 형태로 바꿔주는 함수인데 pem은 위에서 설명했으니 생략하겠다.

그럼 위의 함수를 이용한 구현은 다음과 같다.

func mimicOpenSSLReqNew(pemPkey []byte) []byte {
	pemPkeyBlock, _ := pem.Decode(pemPkey)
	pkey, err := x509.ParsePKCS1PrivateKey(pemPkeyBlock.Bytes)
	if err != nil {
		panic(err)
	}
	cert := &x509.Certificate{
		Subject: pkix.Name{
			Country:    []string{"KR"},
			CommonName: "Hello",
		},
		SerialNumber: big.NewInt(0),
		NotBefore:    time.Now(),
		NotAfter:     time.Now().Add(100 * time.Hour),
		KeyUsage:     x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
	}
	rawCert, err := x509.CreateCertificate(rand.Reader, cert, cert, &pkey.PublicKey, pkey)
	if err != nil {
		panic(err)
	}
	return pem.EncodeToMemory(&pem.Block{
		Type: "CERTIFICATE",
		Bytes: rawCert,
	})
}

코드는 간단하다. 위의 코드는 100시간동안 유효하고 디지털 인증에 활용 가능한 인증서를 만드는 명령어다.
x509.Certificate 안에 들어가는 값들을 간단히 알아보면 지금 부터 사용 가능하고, 100시간 후까지 사용 가능하며, 다른 키를 암호화시키는데에 이용되며, 디지털 인증에 쓰이고, 서버 인증, 클라이언트 인증에 쓰일 수 있는 인증서라는 뜻이다.

x509.CreateCertificate는 매개변수가 좀 많은데 rand.Reader다들 대충 예상할테니 생략하면 뒤의 cert, cert 가 나오는데 이는 템플릿과 부모 인증서를 넣는 공간이다.

원래 인증서는 일종의 트리 구조로 가장 상위의 루트 인증서를 제외하면 부모 인증서를 가진다. 라는 사실을 알 것이다.
이때 저 세번째 매개변수는 자신의 부모 인증서를 넣는 공간이다. 유효한 인증서를 만들기 위해서는 부모 인증서가 필요하기에 이런 매개변수가 있다. 다만 우리는 자가 서명 인증서를 쓸 것이기에(루트 인증서들이 이런 구조인데 우리는 신뢰할 수 있는 기관이 아니므로, 유효하지만 신뢰할 수 없는 자가 서명 인증서밖에 만들 수 밖에 없다.)

여기서 내가 아직 안 이야기한 부분은 pem.Decodex509.ParsePKCS1PrivateKey인데 아마 이 부분은 굳이 이야기하지 않아도 예상한 대로 pem과 x509로 변환된 rsa 개인키를 복호하는 과정이다.

실제 인증서 사용

그럼 이제부터 인증서는 모두 준비되었으니 실제 이 인증서를 써 보자.

만약 당신이 openssl로 파일을 생성했다면 편하게 http.ListenAndServeTLS를 이용하면 되고 실제로도 위 코드에서

pemPKCS1 := mimicOpenSSLGenrsa()
pemCERT := mimicOpenSSLReqNew(pemPKCS1)
ioutil.WriteFile("./pkey.pem", pemPKCS1, 0644)
ioutil.WriteFile("./cert.pem", pemCERT, 0644)

아래와 같은 부분을 통해 파일로 저장하는 것도 가능하다.
하지만 기왕 여기까지 온거 파일로 내보내지 않고 메모리상에 있는 데이터를 바로 써 보자.

이런 경우 쓰라고 Go에서는 tls.X509KeyPair를 지원한다.
해당 매개변수로 메모리상에 로딩된 x509 개인키와 인증서를 제공하면 tls 암호화를 위해 준비된 객체를 리턴하는 함수로 아래와 같이 사용하면 된다.

tcpconn, err := net.Listen("tcp", ":https")
if err != nil {
	panic(err)
}
tlscfg, err := tls.X509KeyPair(pemCERT, pemPKCS1)
if err != nil {
	panic(err)
}
tlsconn := tls.NewListener(tcpconn, &tls.Config{
	Certificates: []tls.Certificate{
		tlscfg,
	},
})
http.Serve(tlsconn, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
	rw.Write([]byte("Hello, World!"))
	rw.WriteHeader(http.StatusOK)
}))

net.Listen 부분은 tcp:https 어드레스, 즉 :443 으로 듣기용 소켓을 만든다는 의미이다.

tls.X509KeyPair는 위에서 준비된 pem 파일을 넣어주면 tls용으로 준비된 인증서 구조체를 리턴한다.

마지막으로 tls.NewListener를 통해 기존에 열린 소켓을 tls 암호화 시킨다.

이제 Go로 만든 인증서로 tcp/tls 소켓까지 열었으니 이 소켓에 http 서버만 제공하면 된다.

그런데 이렇게 직접 연 소켓은 http.ListenAndServe로 열 수 없기에 다른 함수를 써야 한다. 보동 데모에 사용되는 http.ListenAndServe는 이름에서 부터 두가지로 구성되었음을 알 수 있는데 바로 ListenServe이다.
여기서 Listen은 우리가 위에서 tls.Listen을 통해 이미 수행했으니 Serve를 찾아보자.

실제로 http.Serve를 찾아보면 첫번째 매개변수로 net.Listener인터페이스를 사용함을 알 수 있다.

이제 코드를 모두 작성하였으니 실행해 보자.

잘 작동하네.

최종 코드는 아래와 같다.

package main

import (
	"crypto/rand"
	"crypto/rsa"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"fmt"
	"math/big"
	"net"
	"net/http"
	"time"
)

func mimicOpenSSLGenrsa() []byte {
	rawPkey, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		panic(err)
	}

	return pem.EncodeToMemory(&pem.Block{
		Type: "RSA PRIVATE KEY",
		// Headers: map[string]string{},
		Bytes: x509.MarshalPKCS1PrivateKey(rawPkey),
	})
}
func mimicOpenSSLReqNew(pemPkey []byte) []byte {
	pemPkeyBlock, _ := pem.Decode(pemPkey)
	pkey, err := x509.ParsePKCS1PrivateKey(pemPkeyBlock.Bytes)
	if err != nil {
		panic(err)
	}
	cert := &x509.Certificate{
		Subject: pkix.Name{
			Country:    []string{"KR"},
			CommonName: "Hello",
		},
		SerialNumber: big.NewInt(0),
		NotBefore:    time.Now(),
		NotAfter:     time.Now().Add(100 * time.Hour),
		KeyUsage:     x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
	}
	rawCert, err := x509.CreateCertificate(rand.Reader, cert, cert, &pkey.PublicKey, pkey)
	if err != nil {
		panic(err)
	}
	return pem.EncodeToMemory(&pem.Block{
		Type: "CERTIFICATE",
		// Headers: map[string]string{},
		Bytes: rawCert,
	})
}

func main() {
	pemPKCS1 := mimicOpenSSLGenrsa()
	pemCERT := mimicOpenSSLReqNew(pemPKCS1)
	fmt.Println(string(pemPKCS1))
	fmt.Println(string(pemCERT))
	fmt.Println()

	tcpconn, err := net.Listen("tcp", ":https")
	if err != nil {
		panic(err)
	}
	tlscfg, err := tls.X509KeyPair(pemCERT, pemPKCS1)
	if err != nil {
		panic(err)
	}
	tlsconn := tls.NewListener(tcpconn, &tls.Config{
		Certificates: []tls.Certificate{
			tlscfg,
		},
	})
	http.Serve(tlsconn, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		rw.Write([]byte("Hello, World!"))
		rw.WriteHeader(http.StatusOK)
	}))
}

위 코드는 깃허브 개인 저장소에 업로드 되어있다.

사실 실용적이지는 않다.

사실 위 코드들은 사실 별로 실용적이지는 않다.
애시당초 x509에 대한 이해와 tls에 대한 이해를 증대시키기 위해 작성한 코드들이니 별로 실용적인 의미는 없다.

다만 처음으로 Go언어로 서버를 작성하는 분들 중 이미 인증서에 대해 알고 있지만 Go 언어는 처음 써보고, Windows 환경에서 openssl이 안 설치되어 있는 경우, 그 분이 마침 학습용도인 경우 해당 코드를 쓰면 openssl을 설치하지 않고도 https 연습이 가능하니 뭐 나름 편할 수도 있을 것 같다.

그런데 뭐 솔직히 실용적인 용도는 없다.

사실 처음부터 재미로 진짜 되나? 하는 심정이였으니까 ㅋㅋ

그래도 이런 토이프로젝트를 수행하며 좀 더 tls에 대해 알게 된 기분이 드니 만족스럽다.

만약 x509에 대해 관심있으면 한번 따라해 보시는 것도 나쁘진 않을 것 같다.

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

0개의 댓글