[CS300 #154] TLS 핸드셰이크 — 처음 만난 서버를 믿고 비밀을 나누는 1-RTT
컴퓨터공학 300 주제 시리즈의 154번째 글이다. 전체 지도는 여기.
한 줄 요약
TLS 핸드셰이크는 (1) 키 교환으로 둘만 아는 비밀을 만들고, (2) 인증서로 상대가 진짜 그 서버인지 확인하고, (3) 지금까지의 대화가 변조되지 않았음을 서로 검증한다. TLS 1.3 은 이 과정을 왕복 한 번에 끝낸다.
왜 필요한가
HTTPS, gRPC, DB 접속, 쿠버네티스 API 서버, 이메일 전송까지 거의 모든 통신이 TLS 위에서 오간다. 그런데 TLS 오류 메시지는 불친절하다. “certificate verify failed”, “handshake failure”, “unknown CA”, “hostname mismatch” 는 각각 다른 단계의 실패다. 핸드셰이크 순서를 알면 어느 단계에서, 누구 설정 때문에 실패했는지 짚을 수 있다.
성능 측면에서도 중요하다. 새 연결마다 TCP 1-RTT 에 TLS 1-RTT 가 더해진다. 장거리 구간에서는 이 왕복이 응답 시간의 상당 부분을 차지한다.
핵심 개념
TLS 가 보장하는 세 가지
| 성질 | 의미 | 수단 |
|---|---|---|
| 기밀성 | 엿들어도 내용을 모른다 | 대칭 암호(AES-GCM, ChaCha20-Poly1305) |
| 무결성 | 바뀌면 안다 | AEAD 의 인증 태그 |
| 인증 | 상대가 그 이름의 주인이다 | X.509 인증서와 서명 |
비대칭 암호는 느리므로 핸드셰이크에서 키를 합의하는 데만 쓰고, 실제 데이터는 빠른 대칭 암호로 보낸다.
TLS 1.3 전체 핸드셰이크
현재 버전 TLS 1.3 은 RFC 8446 이다.
클라이언트 서버
ClientHello
+ supported_versions, cipher_suites
+ key_share (ECDHE 공개값)
+ server_name (SNI), ALPN -------->
ServerHello
+ key_share (서버 공개값)
── 여기서부터 암호화 ──
{EncryptedExtensions}
{Certificate}
{CertificateVerify}
<-------- {Finished}
{Finished} -------->
[애플리케이션 데이터] <-------> [애플리케이션 데이터]
- ClientHello: 클라이언트가 지원하는 암호 스위트와 함께, 키 교환용 공개값(key_share)을 미리 보낸다. 서버가 어느 그룹을 고를지 추측해서 보내는 것이다. 이것이 TLS 1.2 보다 왕복이 하나 준 비결이다.
- ServerHello: 서버도 자기 공개값을 보낸다. 이제 양쪽은 (EC)DHE 로 같은 공유 비밀을 계산할 수 있고, 거기서 핸드셰이크 키를 뽑는다. 이후 메시지는 모두 암호화된다.
- Certificate + CertificateVerify: 서버가 인증서 체인을 보내고, 지금까지의 핸드셰이크 기록에 자기 개인키로 서명한다. “인증서의 주인이 지금 이 대화에 참여하고 있다”는 증명이다.
- Finished: 양쪽이 핸드셰이크 전체 기록에 대한 MAC 을 교환한다. 중간자가 ClientHello 의 암호 스위트 목록을 몰래 약한 것으로 바꿨다면 여기서 들킨다.
키 교환에 매번 새 임시 키(ephemeral)를 쓰므로, 나중에 서버 개인키가 유출돼도 과거 대화는 복호화할 수 없다. 이를 전방 비밀성(forward secrecy)이라 한다. TLS 1.3 은 이를 필수로 만들었다.
TLS 1.2 와 비교
TLS 1.2(RFC 5246)는 서버 인증서와 키 교환 메시지를 받은 뒤에야 클라이언트가 키 교환 값을 보내므로 핸드셰이크가 2-RTT 다. RSA 키 전송 방식처럼 전방 비밀성이 없는 선택지도 있었다. TLS 1.3 은 오래된 알고리즘(RSA 키 전송, CBC 모드, RC4, SHA-1 서명 등)을 걷어 내고 AEAD 만 남겼다.
인증서 검증
클라이언트는 받은 인증서를 이렇게 검증한다(X.509 프로필은 RFC 5280).
- 체인 구성: 서버 인증서 → 중간 CA → 신뢰하는 루트 CA 까지 서명이 이어지는가.
- 유효 기간: 지금이 notBefore 와 notAfter 사이인가.
- 이름: 접속한 호스트 이름이 인증서의 SAN(Subject Alternative Name)에 있는가.
- 용도와 폐기 여부: 서버 인증용인가, 폐기되지 않았는가.
자주 보는 실패는 서버가 중간 CA 인증서를 빼먹고 보내는 경우다. 브라우저는 캐시된 중간 인증서로 넘어가기도 해서 “브라우저는 되는데 curl·Java 는 안 된다”는 혼란을 낳는다.
SNI 와 ALPN
- SNI(Server Name Indication, RFC 6066): ClientHello 에 접속하려는 호스트 이름을 담는다. IP 하나에 여러 인증서를 둔 서버가 알맞은 인증서를 고른다. 기본적으로 평문이라 경로 위에서 보인다.
- ALPN(RFC 7301): 같은 핸드셰이크에서 위에 올릴 프로토콜(
h2,http/1.1)을 협상한다.
재개와 0-RTT
TLS 1.3 은 이전 연결에서 받은 PSK(사전 공유 키)로 다음 연결을 빠르게 재개할 수 있다. 이때 첫 메시지에 애플리케이션 데이터를 실어 보내는 0-RTT 도 가능하다. 단, 0-RTT 데이터는 재전송 공격에 취약해 멱등 요청에만 써야 한다.
직접 해 보기
임시 자체 서명 인증서를 만들고, 로컬호스트에서 TLS 서버·클라이언트로 핸드셰이크를 해 본다. 정상 접속, 이름 불일치, 신뢰하지 않는 인증서 세 경우를 본다. openssl 명령이 필요하다.
import ssl, socket, subprocess, threading, tempfile, os
d = tempfile.mkdtemp()
cert, key = os.path.join(d, "cert.pem"), os.path.join(d, "key.pem")
subprocess.run(["openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1",
"-nodes", "-keyout", key, "-out", cert, "-days", "1", "-subj", "/CN=localhost",
"-addext", "subjectAltName=DNS:localhost"], check=True, capture_output=True)
sctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
sctx.load_cert_chain(cert, key)
sctx.set_alpn_protocols(["h2", "http/1.1"])
seen_sni = []
sctx.sni_callback = lambda sock, name, ctx: seen_sni.append(name)
lsock = socket.create_server(("127.0.0.1", 0)); port = lsock.getsockname()[1]
def server(n):
for _ in range(n):
raw, _ = lsock.accept()
try:
with sctx.wrap_socket(raw, server_side=True) as s:
s.sendall(b"hello over TLS")
except (ssl.SSLError, OSError):
pass
threading.Thread(target=server, args=(3,), daemon=True).start()
def connect(hostname, trust=True):
ctx = ssl.create_default_context(cafile=cert if trust else None)
ctx.set_alpn_protocols(["h2", "http/1.1"])
try:
with socket.create_connection(("127.0.0.1", port)) as raw, \
ctx.wrap_socket(raw, server_hostname=hostname) as s:
print("성공:", s.version(), s.cipher()[0], "ALPN=" + s.selected_alpn_protocol(), s.recv(100))
except ssl.SSLCertVerificationError as e:
print("검증 실패:", e.verify_message)
connect("localhost") # 정상
connect("wrong.example") # 인증서 SAN 에 없는 이름
connect("localhost", trust=False) # 자체 서명 인증서를 신뢰 목록에 넣지 않음
print("서버가 본 SNI:", seen_sni)
성공: TLSv1.3 TLS_AES_256_GCM_SHA384 ALPN=h2 b'hello over TLS'
검증 실패: Hostname mismatch, certificate is not valid for 'wrong.example'.
검증 실패: self-signed certificate
서버가 본 SNI: ['localhost', 'wrong.example', 'localhost']
세 실패는 모두 클라이언트의 검증 단계에서 났다. 서버는 똑같이 동작했다. 서버가 SNI 로 wrong.example 를 받은 것도 보인다. SNI 가 핸드셰이크 첫 메시지에 평문으로 실린다는 뜻이다. 선택된 암호 스위트 이름은 OpenSSL 버전과 설정에 따라 다를 수 있다.
현업에서는
openssl s_client -connect host:443 -servername host -showcerts로 서버가 보내는 체인을 그대로 본다. 중간 인증서 누락, 만료, 이름 불일치를 한 번에 확인할 수 있다.- 인증서 만료는 가장 흔한 TLS 장애다. cert-manager 같은 자동 갱신 도구를 써도, 갱신 실패 알림과 만료일 모니터링은 따로 둔다.
- 쿠버네티스에서는 Ingress 가 TLS 를 종료하고 백엔드로는 평문 HTTP 를 보내는 구성이 흔하다. 클러스터 안 구간도 암호화해야 하면 백엔드 TLS 나 서비스 메시의 mTLS 를 쓴다.
- 미국 연방 기관의 TLS 구성 지침인 NIST SP 800-52 Rev. 2 는 모든 정부 TLS 서버·클라이언트가 FIPS 기반 암호 스위트로 구성한 TLS 1.2 를 지원하도록 하고, 2024년 1월 1일까지 TLS 1.3 지원을 요구했다. 사내 기준을 정할 때 참고하기 좋다.
확인 문제
- TLS 1.3 핸드셰이크가 TLS 1.2 보다 왕복이 하나 적은 이유는?
- CertificateVerify 메시지가 없다면 어떤 공격이 가능해지는가?
- 전방 비밀성이란 무엇이며, TLS 1.3 에서 이를 보장하는 수단은?
- “브라우저에서는 되는데 curl 에서는 certificate verify failed” 가 나는 흔한 원인은?
- SNI 가 필요한 이유와, SNI 의 프라이버시 문제는?
풀이
- 클라이언트가 ClientHello 에 키 교환용 공개값(key_share)을 미리 담아 보내므로, 서버가 첫 응답에서 바로 키 합의를 끝낼 수 있기 때문이다.
- 인증서는 공개 정보라 누구나 보낼 수 있다. 개인키로 핸드셰이크 기록에 서명하는 단계가 없으면 남의 인증서를 내미는 사칭을 막을 수 없다.
- 장기 개인키가 나중에 유출돼도 과거 세션을 복호화할 수 없는 성질이다. 연결마다 새 임시 키로 하는 (EC)DHE 키 교환으로 보장한다.
- 서버가 중간 CA 인증서를 체인에 넣지 않은 경우다. 브라우저는 캐시나 자동 수집으로 보완하지만 curl 은 그러지 않는다.
- 한 IP 에 여러 도메인과 인증서를 둔 서버가 알맞은 인증서를 고르기 위해 필요하다. 하지만 ClientHello 에 평문으로 실려 접속 대상 도메인이 경로 위에 노출된다.
더 읽을거리 (References)
- RFC 8446, The Transport Layer Security (TLS) Protocol Version 1.3: https://www.rfc-editor.org/rfc/rfc8446.html
- RFC 5280, Internet X.509 Public Key Infrastructure Certificate and CRL Profile: https://www.rfc-editor.org/rfc/rfc5280.html
- NIST SP 800-52 Rev. 2, Guidelines for the Selection, Configuration, and Use of TLS Implementations: https://csrc.nist.gov/pubs/sp/800/52/r2/final
- Python
ssl문서: https://docs.python.org/3/library/ssl.html