Go

만들면서 배우는 Https 서버 시리즈 2

dev_roach 2024. 11. 7. 18:38
728x90

지난번 시리즈에서는 간단하게 인증서를 이용한 Https 서버를 구축하는 작업을 진행했었다.

https://devroach.tistory.com/185

 

만들면서 배우는 Https 서버 시리즈 1

개요요즘 유행하는 Cursor editor 를 써볼겸 사이드에서 간단하게 코드짤때 많이 이용하는 Go 언어를 통해 Https 가 어떻게 동작하는지 가볍게 실습겸 정리하기 위해 직접 코드를 작성하며 정리해보

devroach.tistory.com

오늘은 지난 글의 마지막에 남아있던 질문인 Server 에서 암호화된 응답을 주면 개인키(private key) 가 없는 클라이언트는 어떻게 복호화하지? 라는 질문을 해결하기 위해 TLS Handshake 과정에 대해 알아보려고 한다.

TLS Handshake

출저: https://www.cloudflare.com/ko-kr/learning/ssl/what-happens-in-a-tls-handshake/

그림을 보면 첫번째로 TCP Handshake 가 일어난다. 이 부분은 대부분 알 것 이라고 생각하고 생략하겠다. TLS Handshake 가 일어나는 부분을 집중적으로 보면 아래와 같은 행위가 일어난다.

  1. 클라이언트가 서버에서 "ClientHello" 라는 문자열을 전송한다. 이때 클라이언트가 사용하는 TLS 버전cypher suite(키 교환 알고리즘, 인증방법 등등), 클라이언트가 임의로 생성한 랜덤 바이트를 보냅니다. 후에 이 랜덤 바이트는 세션키(session-key) 를 만드는데 이용됩니다.
  2. 그 이후 서버에서는 SSL Sertificate 를 보내고, cypher suits, 서버측에서 임의로 생성한 랜덤 바이트를 보냅니다.
  3. 지난 글에서 설명한 것과 같이 SSL Sertificate 에는 내가 철수고 어떤 곳에서 일해 등등의 정보가 적혀있으므로 클라이언트는 받은 SSL Sertificate 를 받은 뒤에 CA 라는 인증서를 발급해주는 기업을 통해 내가 접근하려는 서버가 진짜 이 서버가 맞는지를 검증하게 된다.
  4. 이후 클라이언트에서 한번더 The premaster secret 이라는 random byte string 을 보내게 된다. 이 The premaster secret 은 public key 로 encrypted 가 되어 있어서, 서버 사이드에서 가진  private key 로 밖에 decrypt 되지 않는다.
  5. 서버는 premaster secret 을 받은 뒤에 자신의 private key 로 복호화를 진행한다.
  6. 여기서 서버와 클라이언트 모두 주고받은 랜덤바이트 들로 session key 를 생성하고 이를 통해 암/복호화를 진행한다.

여기까지 이론적으로 이해했다면 서버에서 응답을 보낼때도 당연하게 session key 로 암호화하고 클라이언트 사이드에서도 이를 복호화 함을 알수 있다. 근데 여기서 멈추기에는 살짝 아쉬운 면이 있다. 조금만 더 깊게 들어가보자.

 

           0
             |
             v
   PSK ->  HKDF-Extract = Early Secret
             |
             +-----> Derive-Secret(., "ext binder" | "res binder", "")
             |                     = binder_key
             |
             +-----> Derive-Secret(., "c e traffic", ClientHello)
             |                     = client_early_traffic_secret
             |
             +-----> Derive-Secret(., "e exp master", ClientHello)
             |                     = early_exporter_master_secret
             v
       Derive-Secret(., "derived", "")
             |
             v
   (EC)DHE -> HKDF-Extract = Handshake Secret
             |
             +-----> Derive-Secret(., "c hs traffic",
             |                     ClientHello...ServerHello)
             |                     = client_handshake_traffic_secret
             |
             +-----> Derive-Secret(., "s hs traffic",
             |                     ClientHello...ServerHello)
             |                     = server_handshake_traffic_secret
             v
       Derive-Secret(., "derived", "")
             |
             v
   0 -> HKDF-Extract = Master Secret
             |
             +-----> Derive-Secret(., "c ap traffic",
             |                     ClientHello...server Finished)
             |                     = client_application_traffic_secret_0
             |
             +-----> Derive-Secret(., "s ap traffic",
             |                     ClientHello...server Finished)
             |                     = server_application_traffic_secret_0
             |
             +-----> Derive-Secret(., "exp master",
             |                     ClientHello...server Finished)
             |                     = exporter_master_secret
             |
             +-----> Derive-Secret(., "res master",
                                   ClientHello...client Finished)
                                   = resumption_master_secret

 

위의 문서는 RFC 8446 에 명시된 TLS Handshake 과정 중 우리가 위에서 언급한 session_key 를 만드는 과정이라고 생각하면 쉽다. 일단 읽기 살짝 복잡할 수 있는데 이 문서를 정확하게 아는 것보다 우리가 코드로 확인할 수 있는 부분이 몇가지 되는지 찾아보자.

	tlsConfig := &tls.Config{
		RootCAs:            certPool,
		InsecureSkipVerify: false, // Set to true if you want to skip certificate verification (not recommended for production)
		MinVersion:         tls.VersionTLS12,
		KeyLogWriter:       os.Stdout, // WARNING: Only use for debugging!
	}

내 코드를 클론 받거나 그대로 잘 작성했다면 위와 같은 부분을 발견할 수 있을 것이다. 여기서 keyLogWriter 에 내가 WARNING 메세지를 달아두었다. 그 이유는 한번 실행시켜보면 알 수 있다. 클라이언트와 서버를 실행시켰을때 아래와 같은 로그를 확인할 수 있다.

CLIENT_HANDSHAKE_TRAFFIC_SECRET cf394009d413f7f2e4698eaa73fc2fe51cdbbc1fe317bedc603ca73e26b0b912 d671300ce8af345abed923a088af5b6a98c8648647633fc4f0086622d42d6e32
SERVER_HANDSHAKE_TRAFFIC_SECRET cf394009d413f7f2e4698eaa73fc2fe51cdbbc1fe317bedc603ca73e26b0b912 6818154be6bd629c0cf9b894b0d5a737b5b4062845613a3d791738b8c15ccbe9
CLIENT_TRAFFIC_SECRET_0 cf394009d413f7f2e4698eaa73fc2fe51cdbbc1fe317bedc603ca73e26b0b912 c2e7fc8ad8fe647944675880ae022362b0b057b376d58287aeb3ba7e11a8dda7
SERVER_TRAFFIC_SECRET_0 cf394009d413f7f2e4698eaa73fc2fe51cdbbc1fe317bedc603ca73e26b0b912 e3e46dfa4a39a6fa061caf5ebae8f1b2c7f242cbd7eb3b871e8e2cdf5f41743c
2024/11/07 18:12:19 connected to 127.0.0.1:8443
TLS Version: 304
Cipher Suite: 1301
Server Name: localhost

 

이 키를 하나하나씩 이해해보면 다음과 같다.

  • CLIENT_HANDSHAKE_TRAFFIC_SECRET: 클라이언트 사이드에서 handshake 시 메세지를 암호화 하는데 쓰인다.
  • SERVER_HANDSHAKE_TRAFFIC_SECRET: 서버 사이드에서 handshake 시 메세지를 암호화 하는데 쓰인다.
  • CLIENT_TRAFFIC_SECRET_0: 클라이언트 사이드에서 어플리케이션 데이터를 암호화 하는데 쓰인다.
  • SERVER_TRAFFIC_SECRET_0: 서버 사이드에서 어플리케이션 데이터를 암호화 하는데 쓰인다.

즉, 이 키들을 이용하여 암/복호화를 진행하므로 클라이언트 사이드와 서버 사이드에서 보안을 유지할 수 있게 된다. 여기서 끝내면 살짝 이론적인 감이 있으니 이 부분을 통해서 어떻게 암/복호화가 이뤄지는지 한번 알아보도록 하자.

RFC 공식문서를 보고 구현

위에서 본 공식문서의 순차도를 보면 HMAC-based Extract-and-Expand Key Derivation Function (HKDF) 를 사용하여 키를 추출하도록 되어 있다. 요즘 사용되는 TLS 1.3 에서는 AES-GCM 같은 대칭키 알고리즘을 통해 암/복호화가 이뤄지므로 AES-GCM 알고리즘을 이용할 예정이다.

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"

	"golang.org/x/crypto/hkdf"
)

// TLS 트래픽 시크릿을 바탕으로 HKDF를 통해 키 생성
func deriveKey(secret []byte, label string, length int) ([]byte, error) {
	hkdf := hkdf.New(sha256.New, secret, nil, []byte(label))
	key := make([]byte, length)
	_, err := io.ReadFull(hkdf, key)
	if err != nil {
		return nil, err
	}
	return key, nil
}

// AES-GCM 암호화 함수
func encryptAESGCM(key, plaintext []byte) (ciphertext, nonce []byte, err error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, nil, err
	}
	aesgcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, nil, err
	}
	nonce = make([]byte, aesgcm.NonceSize())
	if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
		return nil, nil, err
	}
	ciphertext = aesgcm.Seal(nil, nonce, plaintext, nil)
	return ciphertext, nonce, nil
}

// AES-GCM 복호화 함수
func decryptAESGCM(key, ciphertext, nonce []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	aesgcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil)
	if err != nil {
		return nil, err
	}
	return plaintext, nil
}

func main() {
	// 예시로 사용할 client_application_traffic_secret_0 값
	secretHex := "cf394009d413f7f2e4698eaa73fc2fe51cdbbc1fe317bedc603ca73e26b0b912"
	secret, _ := hex.DecodeString(secretHex)

	// 파생된 AES 키 생성
	label := "tls13 derived key"
	key, err := deriveKey(secret, label, 32) // 256비트 키 생성
	if err != nil {
		fmt.Println("키 파생 실패:", err)
		return
	}
	fmt.Printf("파생된 키: %x\n", key)

	// 암호화할 평문
	plaintext := []byte("Hello, TLS encryption test!")

	// AES-GCM 암호화
	ciphertext, nonce, err := encryptAESGCM(key, plaintext)
	if err != nil {
		fmt.Println("암호화 실패:", err)
		return
	}
	fmt.Printf("암호문: %x\n", ciphertext)
	fmt.Printf("Nonce: %x\n", nonce)

	// AES-GCM 복호화
	decryptedText, err := decryptAESGCM(key, ciphertext, nonce)
	if err != nil {
		fmt.Println("복호화 실패:", err)
		return
	}
	fmt.Printf("복호화된 평문: %s\n", decryptedText)
}

 

이제 실행시켜보면 아래와 같이 입력 결과를 얻을 수 있다.

파생된 키: 0b3e37631f2e39b5d0fe3681bcdb1772910ea15a54b3db2fa00b9629204c3ec2
암호문: 099e580f55fe9848c11a3cb40f33bf200d075da6025909cfae5e586c6921e2798823d9c22e55a5f4f20caa
Nonce: d21327de174673050cc1b855
복호화된 평문: Hello, TLS encryption test!

 

마치며

HTTPS 가 뭔지 이론적으로는 이해가 가지만 실제로 보지못해 막막한 경우가 많은데 이 코드를 통해 어느정도 이해가 갔으면 하는 바람이다. 사실 이 정도로도 겉할기 수준으로 아는거긴 하지만, 가끔 코드로 이런것들을 구현하다보면 조금 더 이해가 잘가고 다른 것들은 어떻게 구현할 수 있을까 고민할 수 있는것 같다.

 

혹시 업데이트를 받고 싶다면 github repository 에 star 를!

 

https://github.com/tmdgusya/https-go

 

GitHub - tmdgusya/https-go

Contribute to tmdgusya/https-go development by creating an account on GitHub.

github.com