iBetter Books
수정

TLS로 소켓 감싸기

앞 장에서 평문 통신의 위험을 똑똑히 봤습니다. 이제 그 해법인 TLS를 우리 소켓에 입혀 봅니다. 놀랍게도 Python에서는 기존 소켓을 TLS로 감싸는 일이 생각보다 간단합니다. 평문 소켓 위에 TLS라는 보호막을 한 겹 씌우는 것입니다. 이 장을 마치면 도청해도 읽을 수 없는 안전한 통신을 만들 수 있습니다.

TLS가 하는 일

TLS는 앞 장에서 말한 세 가지를 한꺼번에 해결합니다. 데이터를 암호화해 엿봐도 못 읽게 하고(기밀성), 변조를 알아채게 하고(무결성), 상대가 진짜인지 확인합니다(인증). 이 모든 일이 연결을 맺는 순간의 의식에서 준비됩니다. 이를 TLS 핸드셰이크라 부릅니다.

TCP의 3-way 핸드셰이크를 떠올려 보세요. TLS 핸드셰이크는 그 위에서 한 번 더 일어나는 인사입니다. TCP 연결이 맺어진 뒤, 양쪽이 어떤 암호 방식을 쓸지 합의하고, 서버가 자신의 신원을 증명하는 인증서를 보여 주고, 이후 통신을 암호화할 비밀 열쇠를 안전하게 나눠 갖습니다. 이 의식이 끝나면, 그다음부터 오가는 모든 데이터는 자동으로 암호화됩니다. 우리는 평소처럼 데이터를 보내고 받기만 하면, 암호화와 복호화는 TLS가 알아서 합니다.

인증서 준비하기

TLS 서버를 만들려면 인증서가 필요합니다. 인증서는 서버의 신분증입니다. "나는 진짜 이 서버가 맞다"를 증명하는 문서로, 암호화에 쓸 공개 열쇠도 담고 있습니다.

실제 서비스에서는 신뢰받는 기관이 발급한 인증서를 씁니다. 브라우저가 그 기관을 믿기에, 그 기관이 보증한 서버도 믿는 구조입니다. 하지만 연습할 때는 우리가 직접 만든 인증서, 곧 자체 서명 인증서로 충분합니다. openssl 명령으로 만듭니다.

# 파일: 터미널openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"

이 명령은 두 개의 파일을 만듭니다. key.pem은 비밀 열쇠로, 절대 남에게 보여 주면 안 되는 서버만의 비밀입니다. cert.pem은 인증서로, 클라이언트에게 보여 주는 신분증입니다. 옵션 중 -nodes는 비밀 열쇠에 암호를 걸지 않겠다는 뜻으로 연습용 설정이고, -days 365는 1년간 유효하다는 뜻입니다.

TLS 에코 서버

이제 인증서를 써서 TLS 에코 서버를 만듭니다.

# 새 파일: tls_server.pyimport socketimport sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)context.load_cert_chain(certfile="cert.pem", keyfile="key.pem")server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server.bind(("127.0.0.1", 9443))server.listen(1)print("TLS 에코 서버 대기 중")with context.wrap_socket(server, server_side=True) as tls_server:    conn, addr = tls_server.accept()    data = conn.recv(1024)    print(f"받음(복호화 후): {data.decode()}")    conn.sendall(data)    conn.close()server.close()

핵심은 세 부분입니다.

먼저 ssl.SSLContext로 TLS 설정을 담는 상자를 만들고, load_cert_chain으로 앞서 만든 인증서와 비밀 열쇠를 불러옵니다. 서버의 신분증과 비밀을 등록하는 것입니다.

그다음은 우리에게 익숙한 TCP 서버 그대로입니다. socket, bind, listen의 흐름이 똑같습니다.

마법은 context.wrap_socket(server, server_side=True)에서 일어납니다. 평범한 TCP 소켓을 TLS로 감싸는 것입니다. 이렇게 감싸고 나면, 그 위에서 accept로 받은 연결은 자동으로 암호화됩니다. 우리가 conn.recv로 받는 데이터는 이미 복호화된 평문이고, conn.sendall로 보내는 데이터는 자동으로 암호화됩니다. 암호화의 복잡한 과정은 TLS가 보이지 않게 처리합니다. 우리 코드는 PART 02의 에코 서버와 거의 같아 보이지만, 그 아래에서 모든 통신이 암호화되고 있습니다.

TLS 에코 클라이언트

클라이언트도 TLS로 감쌉니다.

# 새 파일: tls_client.pyimport socketimport sslcontext = ssl.create_default_context()context.check_hostname = Falsecontext.verify_mode = ssl.CERT_NONEraw = socket.socket(socket.AF_INET, socket.SOCK_STREAM)with context.wrap_socket(raw, server_hostname="localhost") as client:    client.connect(("127.0.0.1", 9443))    print(f"TLS 버전: {client.version()}")    client.sendall("비밀 메시지".encode())    print(f"메아리: {client.recv(1024).decode()}")

클라이언트는 ssl.create_default_context로 기본 설정을 만듭니다. 그런데 그 아래 두 줄을 눈여겨보세요. check_hostnameverify_mode를 꺼 두었습니다. 이것은 우리가 만든 자체 서명 인증서를 받아들이기 위한 연습용 설정입니다.

원래 클라이언트는 서버의 인증서가 신뢰받는 기관이 보증한 진짜인지 검증합니다. 앞 장에서 말한 인증, 곧 상대가 진짜인지 확인하는 단계입니다. 그런데 우리가 직접 만든 인증서는 어떤 기관도 보증하지 않았으니 검증을 통과하지 못합니다. 그래서 연습에서는 검증을 꺼서 받아들이는 것입니다. 다만 이것은 어디까지나 연습용이며, 실제 서비스에서는 절대 끄면 안 된다는 점을 분명히 기억해야 합니다. 검증을 끄면 앞 장에서 본 중간자 공격에 그대로 노출되기 때문입니다.

실행하고 확인하기

서버를 띄우고 클라이언트를 실행합니다.

# 파일: 터미널 2 (클라이언트)python3 tls_client.py
TLS 버전: TLSv1.3
메아리: 비밀 메시지

클라이언트가 사용한 TLS 버전이 찍히고, 메아리가 무사히 돌아왔습니다. 버전이 TLSv1.3으로 나온 것은 현재 가장 최신이자 가장 안전한 TLS가 자동으로 선택됐다는 뜻입니다. 우리가 버전을 고르지 않아도, TLS가 양쪽이 지원하는 가장 좋은 방식을 알아서 합의한 것입니다.

이제 결정적 확인을 해 봅니다. 앞 장에서 평문 채팅을 엿보던 그 tcpdump로, 이번엔 TLS 통신을 들여다보세요. 평문 때와 달리, 화면에는 알아볼 수 없는 암호화된 바이트만 흐릅니다. "비밀 메시지"라는 글자는 어디에도 보이지 않습니다. 도청해도 읽을 수 없는 통신, 우리가 그것을 만들어 낸 것입니다.

감싸는 것만으로 충분하다

이 장의 가장 큰 교훈은 TLS가 기존 소켓을 감싸는 방식으로 동작한다는 점입니다. 우리가 PART 02부터 쌓아 온 소켓 지식은 그대로 유효합니다. 그 위에 보호막을 한 겹 씌웠을 뿐입니다. 그래서 TLS는 어렵지 않습니다. 인증서를 준비하고, 소켓을 감싸면 됩니다.

다음 장에서는 보안과는 또 다른 축, 어떤 상황에서도 무너지지 않는 견고한 서버를 만드는 패턴들을 다룹니다.