안동민 개발노트

본문 시작

TLS 핸드셰이크

TLS 1.2와 1.3이 암호 조합·서버 신원·트래픽 키를 합의하는 흐름과 인증서 체인 검증을 추적합니다.

이 절은 TCP 위에서 TLS를 쓰는 HTTPS 연결을 다룹니다. HTTP/3는 QUIC에 TLS 1.3 핸드셰이크를 결합하므로 아래 TCP 왕복 계산과 구분합니다.

먼저 TLS 핸드셰이크로 “어떤 버전과 암호 조합을 쓸지”, “서버가 그 서비스 이름에 맞는 인증서를 제시했는지”, “이번 연결에만 쓸 트래픽 키를 어떻게 만들지”를 합의합니다.


TLS 핸드셰이크가 정하는 것

인증서로 서버를 인증하는 전체 핸드셰이크의 결과는 크게 세 가지입니다.

첫째, 클라이언트와 서버가 TLS 버전과 암호 조합을 고릅니다.

둘째, 인증서와 서명으로 서버 신원을 확인합니다.

셋째, ECDHE 같은 임시 키 교환으로 공유 비밀을 만들고, 거기서 이번 연결의 여러 트래픽 키를 파생합니다.


전체 핸드셰이크의 왕복

TLS 1.2와 1.3의 전체 핸드셰이크 왕복

TLS 1.2와 1.3의 전체 핸드셰이크 왕복

TLS 전체 핸드셰이크 비교인증서로 서버를 인증하는 ECDHE 연결의 메시지 묶음을 비교한다. 별표는 암호화된 메시지이다.TLS 1.2 · 전체 핸드셰이크클라이언트서버ClientHelloServerHello · CertificateServerKeyExchangeServerHelloDoneClientKeyExchangeCCS · Finished*CCS · Finished*2 RTT 뒤 HTTP 요청TLS 1.3 · 전체 핸드셰이크클라이언트서버ClientHello + key_shareServerHello + key_shareEncryptedExtensions*Certificate* · CertificateVerify*Finished*Finished* · HTTP 요청*1 RTT 뒤 HTTP 요청ServerHello 뒤 핸드셰이크도 암호화
TLS 전체 핸드셰이크 비교위는 TLS 1.2, 아래는 TLS 1.3이다. 시간은 각각 위에서 아래로 흐른다.TLS 1.2 · 전체 핸드셰이크클라이언트서버ClientHelloServerHello · CertificateServerKeyExchangeServerHelloDoneClientKeyExchangeCCS · Finished*CCS · Finished*2 RTT 뒤 HTTP 요청TLS 1.3 · 전체 핸드셰이크클라이언트서버ClientHello + key_shareServerHello + key_shareEncryptedExtensions*Certificate* · CertificateVerify*Finished*Finished* · HTTP 요청*1 RTT 뒤 HTTP 요청ServerHello 뒤 핸드셰이크도 암호화

인증서 기반 ECDHE의 정상 경로입니다. 재개·0-RTT·HelloRetryRequest·클라이언트 인증은 제외했습니다. 화살표는 메시지 묶음이며 패킷 개수가 아닙니다. *는 암호화, CCS는 ChangeCipherSpec입니다. TCP 연결 시간은 별도입니다.

일반적인 TLS 1.2 full handshake는 TLS만 보면 2 RTT가 필요합니다.

TCP 3-way handshake까지 합치면, HTTP 요청을 안전하게 보내기까지 보통 3 RTT가 걸립니다.

단, 세션 재개, False Start, TCP Fast Open 같은 최적화가 있으면 실제 지연은 달라질 수 있습니다.

  • 버전 협상: TLS 1.2 — ClientHello, ServerHello; TLS 1.3 — ClientHello의 supported_versions 확장으로 협상.
  • 키 교환: TLS 1.2 — RSA key transport, DHE, ECDHE 등이 가능; TLS 1.3 — 일반 full handshake는 (EC)DHE key_share, 재개는 PSK 또는 PSK+(EC)DHE.
  • 인증: TLS 1.2 — 인증서와 ServerKeyExchange 서명 등; TLS 1.3 — CertificateVerify로 핸드셰이크 transcript에 서명.
  • 데이터 보호: TLS 1.2 — CBC, GCM 등 여러 방식; TLS 1.3 — AEAD 기반 cipher suite만 사용.

Cipher Suite 읽기

TLS 1.2의 cipher suite 이름은 키 교환, 인증, 대칭 암호, 해시가 한 줄에 들어 있습니다.

예를 들어 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256은 ECDHE로 공유 비밀을 만들고, RSA 인증서 키로 서버 서명을 검증하며, AES-128-GCM으로 레코드를 보호하고, SHA-256을 키 유도와 Finished 검증에 사용한다는 뜻입니다.

TLS 1.3의 cipher suite 이름은 더 짧습니다.

TLS_AES_128_GCM_SHA256처럼 데이터 보호 알고리즘과 해시만 담고, 키 교환과 인증 방식은 별도 확장과 인증서 알고리즘으로 협상합니다.


디피-헬만 키 교환

ECDHE(Elliptic Curve Diffie-Hellman Ephemeral)는 현재 TLS에서 가장 중요한 키 교환 방식입니다.

양쪽은 사용할 곡선 그룹과 공개 key share를 교환하며, 각자의 임시 비밀값은 보내지 않습니다. 기준점은 선택한 그룹의 정의에 포함됩니다.

양쪽은 상대의 공개값과 자신의 비밀값을 조합해 같은 공유 비밀에 도달합니다.

다만 순수한 디피-헬만만으로는 상대가 누구인지 인증하지 못하므로, TLS는 인증서와 서명으로 공개값이 진짜 서버 흐름에 속했는지 확인합니다.

여기서 E(Ephemeral)는 장기 인증 키와 별개의 임시 키를 쓴다는 뜻입니다. 연결마다 새 임시 키를 만들고 폐기하는 것이 전방 비밀성을 유지하는 데 중요합니다.

서버의 장기 개인키가 나중에 유출되어도, 과거 연결의 임시 비밀값이 안전하게 폐기되었고 세션 키 로그나 티켓 키 유출 같은 별도 사고가 없다면 녹화된 과거 트래픽을 다시 풀기 어렵습니다.

이 성질을 전방 비밀성(Forward Secrecy)이라고 합니다.


TLS 1.3의 개선점

TLS 1.3은 2018년에 표준화되었고, TLS 1.2보다 핸드셰이크를 짧고 단단하게 만들었습니다.

클라이언트가 ClientHello에 (EC)DHE key_share를 미리 싣기 때문에, 일반적인 full handshake는 1 RTT로 진행됩니다. 서버는 ServerHello 뒤부터 핸드셰이크를 암호화하며, 클라이언트는 서버 인증과 Finished 검증 뒤 요청을 보냅니다. key_share가 맞지 않아 HelloRetryRequest가 발생하면 왕복이 추가됩니다.

정적 RSA/DH 키 교환과 TLS 재협상은 TLS 1.3에서 제거되었습니다. 재개 연결에서는 일부 데이터를 0-RTT로 보낼 수 있지만 다음의 보안 제약이 따릅니다.

0-RTT는 “재접속을 더 빨리 시작할 수 있는 기능”이지, 기본으로 켜 둘 만능 최적화가 아닙니다.

0-RTT early data는 연결 간 재생 공격 방지를 보장하지 않고 전방 비밀성도 없습니다. HTTP 메서드 이름만 보고 안전하다고 판단하지 말고, 애플리케이션이 중복 실행을 감당할 수 있는 작업에만 제한해야 합니다.

아래 명령은 출력 항목을 살펴보는 진단 예시이며 이 교재에서 실행한 관측 결과가 아닙니다. 출력 형식에 따라 grep 결과가 비어도 연결 실패를 뜻하지는 않습니다. s_client는 기본적으로 인증서 오류 뒤에도 진행할 수 있으므로, 서비스 신원 검증을 실패 조건으로 검사할 때는 적절한 신뢰 저장소와 -verify_hostname 및 -verify_return_error가 필요합니다.

tls_check.sh
# SNI를 명시해 TLS 1.3 연결이 되는지 확인
openssl s_client -connect example.com:443 -servername example.com -tls1_3 </dev/null 2>/dev/null | grep "Protocol"

# 협상된 cipher suite 확인
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | grep "Cipher"

# leaf 인증서의 주체와 유효기간 확인
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

# 서버가 보내는 인증서 체인 확인
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null \
  | grep -E "s:|i:"

인증서와 서버 신원

TLS 핸드셰이크에서 서버가 보내는 X.509 인증서는 “이 공개키는 이 서비스 이름에 묶여 있고, 신뢰할 수 있는 CA가 그 사실을 서명했다”는 주장입니다.

브라우저는 단순히 “인증서가 있다”만 보지 않습니다.

접속한 DNS 이름이 인증서의 subjectAltName(dNSName)에 있는지, 유효기간 안인지, 용도와 확장이 맞는지, 그리고 인증서 체인이 신뢰 저장소의 trust anchor까지 이어지는지를 함께 확인합니다.

현대 서비스 이름 검증은 Common Name(CN) 대신 SAN을 사용합니다. IP 주소로 접속할 때는 SAN의 iPAddress와 맞는지도 확인합니다.


인증서 체인 검증

인증서 서명과 로컬 신뢰의 연결

인증서 서명과 로컬 신뢰의 연결

인증서 서명 관계신뢰 앵커가 중간 CA 인증서에, 중간 CA가 서버 인증서에 서명하는 일반적인 경로이다.서버 인증서서비스 이름 · 공개키중간 CA발급자 공개키신뢰 앵커클라이언트가 신뢰서명서명검증은 서버 인증서에서 신뢰 앵커 방향으로 경로를 구성
인증서 서명 관계위의 신뢰 앵커에서 중간 CA와 서버 인증서로 서명 관계가 내려간다.신뢰 앵커로컬 신뢰 저장소중간 CA발급자 공개키서버 인증서서비스 이름 · 공개키서명서명

서버가 보낸 루트 인증서라는 이유만으로 신뢰하지 않습니다. 이 그림은 CA 서명 관계이며, 접속 이름·유효기간·용도 검증과 서버의 개인키 소유 증명은 별도로 필요합니다.

서버는 보통 leaf 인증서와 intermediate CA 인증서를 보냅니다.

루트 CA 인증서는 대개 서버가 보내는 대상이 아니라, 운영체제나 브라우저의 신뢰 저장소에 들어 있는 trust anchor입니다.

루트 CA가 모든 서버 인증서를 직접 서명하지 않는 이유는 운영과 사고 격리 때문입니다.

루트 개인키는 오프라인에 가깝게 보호하고, 실제 발급 업무는 중간 CA가 맡습니다.

중간 CA에 문제가 생기면 해당 중간 CA를 폐기하거나 교체할 수 있지만, 루트 CA가 유출되면 그 루트에 기대는 신뢰 체계 전체가 흔들립니다.

다음 절에서는 실무에서 인증서를 발급하고 갱신하는 방법을 살펴보겠습니다.