Go

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

dev_roach 2024. 11. 6. 22:30
728x90

개요

요즘 유행하는 Cursor editor 를 써볼겸 사이드에서 간단하게 코드짤때 많이 이용하는 Go 언어를 통해 Https 가 어떻게 동작하는지 가볍게 실습겸 정리하기 위해 직접 코드를 작성하며 정리해보았다. 막상 이런 자료들 볼때 실습으로 할수 있는 코드들은 많이 없고, 이론적인 자료만 너무 많아서 정말 아쉽다.

Https 란?

Https 는 Http 와 다른 프로토콜로 인터넷 통신간 TLS/SSL encryption 을 이용하여 더 안전하게 데이터를 주고 받기 위한 하나의 프로토콜이다. 쉽게 예시를 들기 위해 아래 그림을 한번 보자.

HTTP 를 사용하게 되면 인터넷 통신간 전송되는 데이터를 평문(Plain-text) 형태로 보내게 된다. 이때 패킷을 가로채는 Packet Sniffers 이 있으면 그대로 평문 정보가 노출되므로 보안적으로 위협을 https 에 비해 쉽게 받을 수 있다.

* Packet Sniffer Tool 은 인터넷에 치면 많이 나오니까 다운받아서 이용해봐도 좋다. 대표적으로 Wireshark 도 Packet sniffer tool 이다.

 

Https 는 이러한 단점을 보안하기 위해 보내지는 데이터에 암호화/복호화 (encryption / decryption) 를 진행한다. 이때 보안 공부를 해봤다면 재미있는 생각을 할 수 있는데 어떻게 서로 암호화를 할까? 라는 생각을 할 수 있다.

예를 들면, 위 그림 처럼 A 라는 키로 암/복호화를 진행한다고 하면 "안녕하세요" 를 A 라는 키로 암호화 할 경우 A 라는 키로 복호화를 해야만 "안녕하세요" 를 잘 복호화 해야 하는데 이때 A 라는 키가 상대적으로 취약한 클라이언트 사이드에서 탈취 당할 경우 사실상 평문으로 노출된것과 마찬가지의 수준이 될수도 있기 때문이다.

Public-Private Key

이때 Public - Private Key 방식을 이용하여 암/복호화를 진행하는데 이 방식은 아주 간단하게 설명하자면 아래와 같다. 예를 들면 철수와 영희가 있다고 해보자.

  1. 철수는 영희에게 암호화가 가능한 키를 제공(Public Key)한다. (이때 영희가 받은 Public key 로는 암호화된 메세지를 복호화 하는 것이 불가능 하다고 해보자)
  2. 영희는 철수에게 메세지를 보낼때 평문을 반드시 제공된 공개키로 암호화 한다. (예시, "안녕하세요" -> "asdfhjksafdhjk1234")
  3. 철수는 영희에게 받은 메세지를 Private Key 를 통해서 복호화를 시도한다. (예시, "asdfhjksafdhjk1234" -> "안녕하세요")

즉, 복호화가 가능한 키는 반드시 철수가 개인적으로 가지고 있기 때문에 누군가 철수의 Private Key 를 훔치지 않는다면 복호화가 상당히 힘듬을 알수 있다. 일단 분명히 더 궁금한 점이 있겠지만 코드 작성부터 진행해보자.

 

더 자세한 설명은 이 블로그를 참조해도 좋다. 잘 설명해주신거 같아서 우리는 코드 위주의 실습이 목적이기 때문에 이걸 보는걸 추천드린다.

코드 실습 1

일단 우리가 public key 와 private key 를 만들기 위해서는 openssl 을 이용할 것이다. 여기서 설치방법까지는 설명하기는 길어질거 같아 본인이 알아서 설치하길 바란다. Open ssl 을 사용하기 위해서는 몇가지 configuration 이 필요한데, 예를 들명 증명서를 발급할때

[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no

[req_distinguished_name]
C = US
ST = State
L = City
O = Organization
OU = OrganizationUnit
CN = localhost

[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names

[alt_names]
DNS.1 = localhost
IP.1 = 127.0.0.1

 

일단 위와 같이 configuration 파일을 만들었으면 가볍게 한번 살펴보자. 위의 블로그의 설명을 읽었다면 알수 있듯이 증명서(certification) 에는 내가 어떠 어떠한 사람인지 알려주는 것이 필요하다. 여기서는 그런 정보와 증명서가 어떤 용도로 어떻게 쓰이는지가 적혀있다고 보면 좋다.

 

이제 Terminal 에 아래와 같은 커맨드를 입력해서 private key 를 만들자. (2048은 2048비트 사이즈의 RSA key 를 생성한다는 뜻이다)

openssl genrsa -out key.pem 2048

 

이제 입력한 conf 를 기반으로 증명서(certificate) 를 만들자.

openssl req -x509 -new -nodes -key key.pem -sha256

 

이제 만들었다면 프로젝트 내부에 cert.pemkey.pem 이 잘 보일 것 이다. key.pem 이 복호화용 키로 노출되면 안되므로 서버사이드에 저장해두면 된다.

Go 코드로 서버 구현

이제 Go 코드로 서버 사이드 코드를 구현해보자. 우리가 구현할 로직은 다음과 같다.

  • cert 와 key 파일을 가져온다.
  • 가져온 증명 파일들을 이용해 TLS 채널을 구축한다.
  • 클라이언트를 통해 받은 메세지를 out channel 에 출력한다
  • 클라이언트에게 받은 메세지를 그대로 응답(respond) 해준다.
package main

import (
	"bufio"
	"crypto/tls"
	"fmt"
	"io"
	"log"
	"net"
	"strings"
)

// loadCertificate loads a TLS certificate and private key from the specified files.
// It returns the loaded certificate or fatally exits if loading fails.
func loadCertificate(certFile, keyFile string) tls.Certificate {
	cert, err := tls.LoadX509KeyPair(certFile, keyFile)
	if err != nil {
		log.Fatalf("failed to load server certificate and key: %v", err)
	}
	return cert
}

func main() {
	cert := loadCertificate("cert.pem", "key.pem")

	// set up a TLS config with the loaded certificate
	tlsConfig := &tls.Config{
		Certificates: []tls.Certificate{cert},
		MinVersion:   tls.VersionTLS12,
	}

	listener, err := tls.Listen("tcp", ":8443", tlsConfig)
	if err != nil {
		log.Fatalf("failed to listen on port 8443: %v", err)
	}
	defer listener.Close()

	log.Println("listening on port 8443")

	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Printf("failed to accept connection: %v", err)
			continue
		}

		go handleConnection(conn)
	}
}

func handleConnection(conn net.Conn) {
	defer conn.Close()

	log.Printf("connection from %s", conn.RemoteAddr())

	// Create a buffered reader for more efficient reading
	reader := bufio.NewReader(conn)

	tlsConn := conn.(*tls.Conn)

	// Get connection state
	state := tlsConn.ConnectionState()
	log.Printf("TLS Version: %x", state.Version)
	log.Printf("Cipher Suite: %x", state.CipherSuite)
	log.Printf("Session Reused: %v", state.DidResume)

	for {
		// Read until newline or max buffer size
		message, err := reader.ReadString('\n')
		if err != nil {
			if err != io.EOF {
				log.Printf("failed to read from connection: %v", err)
			}
			return
		}

		message = strings.TrimSpace(message)
		log.Printf("received from %s: %q", conn.RemoteAddr(), message)

		// Send response with newline for proper framing
		response := fmt.Sprintf("Server received: %s\n", message)
		if _, err := conn.Write([]byte(response)); err != nil {
			log.Printf("failed to write to connection: %v", err)
			return
		}
	}
}

 

만약 Go 코드를 많이 작성했다면 익숙할 것이다. 가볍게 설명하자면 아래와 같다.

func loadCertificate(certFile, keyFile string) tls.Certificate {
	cert, err := tls.LoadX509KeyPair(certFile, keyFile)
	if err != nil {
		log.Fatalf("failed to load server certificate and key: %v", err)
	}
	return cert
}

 

위 코드는 인자로 받은 certFile (public key)keyFile (private key) 을 불러와서 Certificate 인스턴스를 만드는데 이용된다. 이 Certificate 정보를 이용해서 TLS Connection 을 만든다. 그 밑의 goroutine 을 통해 handle 하는 과정은 Connection 에서 받아온 메세지를 읽고 다시 응답하는 부분이다.

클라이언트 사이드 코드 작성

이제 클라이언트 사이드에서의 코드도 작성해보자.

package main

import (
	"bufio"
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"log"
	"os"
	"strings"
)

func loadRootCA(certFile string) *x509.CertPool {
	// Create a certificate pool for trusted certificates
	caCert, err := os.ReadFile(certFile)
	if err != nil {
		log.Fatalf("failed to read CA certificate: %v", err)
	}

	certPool := x509.NewCertPool()
	if ok := certPool.AppendCertsFromPEM(caCert); !ok {
		log.Fatal("failed to append CA certificate")
	}

	return certPool
}

func main() {
	// Load the self-signed certificate
	certPool := loadRootCA("../https-server/cert.pem")

	// Configure TLS client
	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!
	}

	// Create a TLS connection
	conn, err := tls.Dial("tcp", "localhost:8443", tlsConfig)
	if err != nil {
		log.Fatalf("failed to connect: %v", err)
	}
	defer conn.Close()

	log.Printf("connected to %s", conn.RemoteAddr())

	// Get connection state information
	state := conn.ConnectionState()
	fmt.Printf("TLS Version: %x\n", state.Version)
	fmt.Printf("Cipher Suite: %x\n", state.CipherSuite)
	fmt.Printf("Server Name: %s\n", state.ServerName)

	// Use bufio for better reading
	reader := bufio.NewReader(os.Stdin)
	connReader := bufio.NewReader(conn)

	// Interactive loop
	for {
		fmt.Print("Enter message (or 'quit' to exit): ")
		message, err := reader.ReadString('\n')
		if err != nil {
			log.Printf("failed to read input: %v", err)
			break
		}

		message = strings.TrimSpace(message)
		if message == "quit" {
			break
		}

		// Add newline for message framing
		message += "\n"
		if _, err := conn.Write([]byte(message)); err != nil {
			log.Printf("failed to write to connection: %v", err)
			break
		}

		// Read response
		response, err := connReader.ReadString('\n')
		if err != nil {
			log.Printf("failed to read from connection: %v", err)
			break
		}

		fmt.Printf("Server response: %q\n", strings.TrimSpace(response))
	}
}

 

여기에서는 클라이언트 사이드 이므로 public key 를 불러온뒤 그것을 이용해 내부에서 암호화를 진행한 뒤 서버 사이드로 콘텐츠를 전송한다. 한번 테스트 해보자.

첫번째 실습 테스트 결과

첫번째 실습을 테스트 해보니 클라이언트 사이드에서 보낸 메세지가 잘 암/복호화 되고 있음을 확인해 볼수 있었다.

의문점

그럼 여기서 의문점이 하나 생긴다.

클라이언트 사이드에서는 비공개키가 없으므로 복호화가 불가능하다면 서버에서 응답을 줄때는 암호화를 하지 않고 주는가??

 

그렇다면 응답은 여전히 packet sniffers 에게 탈취됬을 경우 위험한가? 라는 생각을 할 수 있다. 하지만 Https 통신에서 서버가 응답을 클라이언트에게 줄때도 암호화를 한뒤 전달해준다. 그걸 어떻게 클라이언트에서 암호화를 할까? 는 글이 길어지므로 2부에서 코드와 함께 적어보도록 하겠다. (hint 는 우리가 client-side 에 설정한 keyLogWriter 에 있다)

Github link

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

 

GitHub - tmdgusya/https-go

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

github.com