컴퓨터공학 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}                          -------->
[애플리케이션 데이터]               <------->       [애플리케이션 데이터]
  1. ClientHello: 클라이언트가 지원하는 암호 스위트와 함께, 키 교환용 공개값(key_share)을 미리 보낸다. 서버가 어느 그룹을 고를지 추측해서 보내는 것이다. 이것이 TLS 1.2 보다 왕복이 하나 준 비결이다.
  2. ServerHello: 서버도 자기 공개값을 보낸다. 이제 양쪽은 (EC)DHE 로 같은 공유 비밀을 계산할 수 있고, 거기서 핸드셰이크 키를 뽑는다. 이후 메시지는 모두 암호화된다.
  3. Certificate + CertificateVerify: 서버가 인증서 체인을 보내고, 지금까지의 핸드셰이크 기록에 자기 개인키로 서명한다. “인증서의 주인이 지금 이 대화에 참여하고 있다”는 증명이다.
  4. 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).

  1. 체인 구성: 서버 인증서 → 중간 CA → 신뢰하는 루트 CA 까지 서명이 이어지는가.
  2. 유효 기간: 지금이 notBefore 와 notAfter 사이인가.
  3. 이름: 접속한 호스트 이름이 인증서의 SAN(Subject Alternative Name)에 있는가.
  4. 용도와 폐기 여부: 서버 인증용인가, 폐기되지 않았는가.

자주 보는 실패는 서버가 중간 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 지원을 요구했다. 사내 기준을 정할 때 참고하기 좋다.

확인 문제

  1. TLS 1.3 핸드셰이크가 TLS 1.2 보다 왕복이 하나 적은 이유는?
  2. CertificateVerify 메시지가 없다면 어떤 공격이 가능해지는가?
  3. 전방 비밀성이란 무엇이며, TLS 1.3 에서 이를 보장하는 수단은?
  4. “브라우저에서는 되는데 curl 에서는 certificate verify failed” 가 나는 흔한 원인은?
  5. SNI 가 필요한 이유와, SNI 의 프라이버시 문제는?

풀이

  1. 클라이언트가 ClientHello 에 키 교환용 공개값(key_share)을 미리 담아 보내므로, 서버가 첫 응답에서 바로 키 합의를 끝낼 수 있기 때문이다.
  2. 인증서는 공개 정보라 누구나 보낼 수 있다. 개인키로 핸드셰이크 기록에 서명하는 단계가 없으면 남의 인증서를 내미는 사칭을 막을 수 없다.
  3. 장기 개인키가 나중에 유출돼도 과거 세션을 복호화할 수 없는 성질이다. 연결마다 새 임시 키로 하는 (EC)DHE 키 교환으로 보장한다.
  4. 서버가 중간 CA 인증서를 체인에 넣지 않은 경우다. 브라우저는 캐시나 자동 수집으로 보완하지만 curl 은 그러지 않는다.
  5. 한 IP 에 여러 도메인과 인증서를 둔 서버가 알맞은 인증서를 고르기 위해 필요하다. 하지만 ClientHello 에 평문으로 실려 접속 대상 도메인이 경로 위에 노출된다.

더 읽을거리 (References)