만들면서 배우는 Https 서버 시리즈 2
지난번 시리즈에서는 간단하게 인증서를 이용한 Https 서버를 구축하는 작업을 진행했었다.
https://devroach.tistory.com/185
오늘은 지난 글의 마지막에 남아있던 질문인 Server 에서 암호화된 응답을 주면 개인키(private key) 가 없는 클라이언트는 어떻게 복호화하지? 라는 질문을 해결하기 위해 TLS Handshake 과정에 대해 알아보려고 한다.
TLS Handshake
그림을 보면 첫번째로 TCP Handshake 가 일어난다. 이 부분은 대부분 알 것 이라고 생각하고 생략하겠다. TLS Handshake 가 일어나는 부분을 집중적으로 보면 아래와 같은 행위가 일어난다.
- 클라이언트가 서버에서 "ClientHello" 라는 문자열을 전송한다. 이때 클라이언트가 사용하는 TLS 버전과 cypher suite(키 교환 알고리즘, 인증방법 등등), 클라이언트가 임의로 생성한 랜덤 바이트를 보냅니다. 후에 이 랜덤 바이트는 세션키(session-key) 를 만드는데 이용됩니다.
- 그 이후 서버에서는 SSL Sertificate 를 보내고, cypher suits, 서버측에서 임의로 생성한 랜덤 바이트를 보냅니다.
- 지난 글에서 설명한 것과 같이 SSL Sertificate 에는 내가 철수고 어떤 곳에서 일해 등등의 정보가 적혀있으므로 클라이언트는 받은 SSL Sertificate 를 받은 뒤에 CA 라는 인증서를 발급해주는 기업을 통해 내가 접근하려는 서버가 진짜 이 서버가 맞는지를 검증하게 된다.
- 이후 클라이언트에서 한번더 The premaster secret 이라는 random byte string 을 보내게 된다. 이 The premaster secret 은 public key 로 encrypted 가 되어 있어서, 서버 사이드에서 가진 private key 로 밖에 decrypt 되지 않는다.
- 서버는 premaster secret 을 받은 뒤에 자신의 private key 로 복호화를 진행한다.
- 여기서 서버와 클라이언트 모두 주고받은 랜덤바이트 들로 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