TLS 핸드셰이크
TLS 핸드셰이크가 버전과 암호 조합, 서버의 신원, 이번 연결의 키를 정하는 과정을 TLS 1.3의 메시지로 따라가고 TLS 1.2와 왕복 수를 비교합니다. 암호 조합의 이름을 읽는 방법, 인증서의 이름과 와일드카드를 맞춰 보는 규칙, 신뢰 앵커까지 이어지는 인증서 사슬의 검증과 openssl로 확인하는 방법을 정리합니다.
앞 글에서 TLS가 키 교환, 전자 서명, AEAD를 조합한다고 했습니다. 이 글에서는 그 조합이 실제로 어떤 메시지들로 이루어지는지 따라갑니다. 대상은 TCP 위의 HTTPS이고, 같은 TLS 1.3 핸드셰이크를 QUIC에 넣은 HTTP/3는 7장 3편에서 본 것처럼 왕복 수가 다릅니다. 핸드셰이크는 연결이 맺어질 때 한 번 일어나지만, 인증서 오류나 느린 첫 접속처럼 실무에서 만나는 많은 문제가 여기서 생깁니다.
핸드셰이크가 정하는 세 가지
TLS 핸드셰이크는 데이터를 보내기 전에 세 가지를 정합니다. 첫째, 양쪽이 지원하는 것 가운데 쓸 TLS 버전과 암호 조합(cipher suite)을 고릅니다. 둘째, 서버가 보낸 인증서와 서명으로 접속하려는 서비스가 맞는지 확인합니다. 셋째, ECDHE 키 교환으로 공유 비밀을 만들고, 거기서 이번 연결에만 쓸 여러 개의 키를 끌어냅니다. 셋 가운데 하나라도 실패하면 연결은 맺어지지 않습니다.
TLS 1.3의 메시지
TLS 1.3(RFC 9846)의 전체 핸드셰이크는 한 번의 왕복으로 끝납니다. 클라이언트가 첫 메시지에 키 교환에 쓸 공개 값까지 미리 실어 보내기 때문입니다.
| 보내는 쪽 | 메시지 | 담는 것 |
|---|---|---|
| 클라이언트 | ClientHello | 지원하는 버전과 암호 조합, 키 교환의 공개 값(key_share), 접속할 도메인 이름(SNI), 쓸 애플리케이션 프로토콜(ALPN) |
| 서버 | ServerHello | 고른 암호 조합과 서버의 키 교환 공개 값. 이 뒤부터는 암호화된다 |
| 서버 | EncryptedExtensions | 고른 ALPN 등 나머지 협상 결과 |
| 서버 | Certificate | 서버 인증서와 중간 CA 인증서 |
| 서버 | CertificateVerify | 지금까지의 핸드셰이크 내용 전체에 대한 서버의 서명 |
| 서버 | Finished | 지금까지의 핸드셰이크가 바뀌지 않았음을 확인하는 HMAC 값 |
| 클라이언트 | Finished, 첫 HTTP 요청 | 클라이언트의 확인 값과 함께 바로 요청을 보낸다 |
ServerHello를 받은 클라이언트는 양쪽의 공개 값으로 공유 비밀을 계산할 수 있으므로, 그 뒤의 메시지는 모두 암호화되어 옵니다. CertificateVerify가 핵심입니다. 서버는 인증서에 든 공개 키의 짝인 개인 키로, 이번 연결에서 주고받은 메시지 전체에 서명합니다. 공격자가 인증서를 복사해 보낼 수는 있어도 개인 키 없이는 이 서명을 만들 수 없고, 서명 대상에 양쪽의 키 교환 공개 값이 들어 있으므로 앞 글에서 본 중간자 공격도 여기서 막힙니다.
TLS 1.2와 1.3의 전체 핸드셰이크 왕복
두 버전에서 클라이언트의 첫 메시지를 보냅니다. 1.2의 서버 CCS·Finished를 확인합니다. 각 단계의 값과 전환 이유를 비교합니다.
TLS · FULL HANDSHAKE
HTTP 요청까지, 왕복 한 번의 차이
TLS 1.3은 첫 메시지에 키 공유값을 담아 서버의 인증과 완료 응답을 한 번에 받습니다. TCP 연결이 끝난 뒤부터 비교합니다.
HTTP 요청까지 2 RTT
TLS 1.2 · 키 교환 후 완료 확인
서버의 Finished를 받아야 요청을 보냅니다.
2 RTT 뒤 클라이언트 → 서버: 암호화한 HTTP 요청.
HTTP 요청까지 1 RTT
TLS 1.3 · 첫 메시지부터 키 공유
서버 인증 후 Finished와 요청을 이어 보냅니다.
1 RTT 뒤 요청을 시작합니다. ServerHello 뒤 핸드셰이크도 암호화됩니다.
- 세로 점선: 시간의 흐름 ↓
- 가로 화살표: 메시지 묶음
- TLS 1.3의 핵심 흐름 / HTTP 요청
*는 암호화된 메시지입니다. CCS는 ChangeCipherSpec이며 TLS 1.2의 암호 상태를 전환합니다. HTTP 요청은 핸드셰이크 메시지가 아니라 애플리케이션 데이터입니다. 화살표 수는 패킷 개수를 뜻하지 않습니다.
인증서 기반 ECDHE의 정상 경로만 비교합니다. TCP 연결 시간은 별도이며, 재개·0-RTT·HelloRetryRequest·클라이언트 인증·TLS 1.2 False Start는 제외했습니다. TLS 1.3의 호환성용 CCS는 생략했습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 ClientHello부터 1.2 서버 완료까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | TLS 1.2와 1.3의 전체 핸드셰이크 왕복 |
| 관계 설명 | 시간은 위에서 아래로 흐릅니다. ClientHello, 서버의 ServerHello·Certificate·ServerKeyExchange·ServerHelloDone, 클라이언트의 ClientKeyExchange·CCS·Finished, 서버의 CCS·Finished 순서 뒤 클라이언트가 암호화 HTTP 요청을 보냅니다. 정상 인증서 기반 ECDHE 전체 핸드셰이크이며 요청까지 TLS 2 RTT, 별표는 암호화된 메시지입니다. |
| 관계 설명 | 시간은 위에서 아래로 흐릅니다. ClientHello와 key_share를 보낸 뒤 서버는 ServerHello와 key_share, 암호화한 EncryptedExtensions·Certificate·CertificateVerify·Finished를 반환합니다. 클라이언트는 서버 인증과 Finished 검증 뒤 자기 Finished와 암호화 HTTP 요청을 이어 보냅니다. 정상 인증서 기반 ECDHE이며 요청까지 TLS 1 RTT입니다. EncryptedExtensions 이름은 화면에서 두 줄로 배치했습니다. 별표는 암호화된 메시지입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| ClientHello 두 버전에서 클라이언트의 첫 메시지를 보냅니다. |
|
|
| 서버 응답 서버의 키 교환과 인증 정보를 받습니다. |
|
|
| 클라이언트 완료 클라이언트의 완료 메시지를 보냅니다. |
|
|
| 1.2 서버 완료 1.2의 서버 CCS·Finished를 확인합니다. |
|
|
TLS 1.2와 1.3의 전체 핸드셰이크 왕복
두 버전에서 클라이언트의 첫 메시지를 보냅니다. 1.2의 서버 CCS·Finished를 확인합니다. 각 단계의 값과 전환 이유를 비교합니다.
TLS · FULL HANDSHAKE
HTTP 요청까지, 왕복 한 번의 차이
TLS 1.3은 첫 메시지에 키 공유값을 담아 서버의 인증과 완료 응답을 한 번에 받습니다. TCP 연결이 끝난 뒤부터 비교합니다.
HTTP 요청까지 2 RTT
TLS 1.2 · 키 교환 후 완료 확인
서버의 Finished를 받아야 요청을 보냅니다.
2 RTT 뒤 클라이언트 → 서버: 암호화한 HTTP 요청.
HTTP 요청까지 1 RTT
TLS 1.3 · 첫 메시지부터 키 공유
서버 인증 후 Finished와 요청을 이어 보냅니다.
1 RTT 뒤 요청을 시작합니다. ServerHello 뒤 핸드셰이크도 암호화됩니다.
- 세로 점선: 시간의 흐름 ↓
- 가로 화살표: 메시지 묶음
- TLS 1.3의 핵심 흐름 / HTTP 요청
*는 암호화된 메시지입니다. CCS는 ChangeCipherSpec이며 TLS 1.2의 암호 상태를 전환합니다. HTTP 요청은 핸드셰이크 메시지가 아니라 애플리케이션 데이터입니다. 화살표 수는 패킷 개수를 뜻하지 않습니다.
인증서 기반 ECDHE의 정상 경로만 비교합니다. TCP 연결 시간은 별도이며, 재개·0-RTT·HelloRetryRequest·클라이언트 인증·TLS 1.2 False Start는 제외했습니다. TLS 1.3의 호환성용 CCS는 생략했습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 ClientHello부터 1.2 서버 완료까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | TLS 1.2와 1.3의 전체 핸드셰이크 왕복 |
| 관계 설명 | 시간은 위에서 아래로 흐릅니다. ClientHello, 서버의 ServerHello·Certificate·ServerKeyExchange·ServerHelloDone, 클라이언트의 ClientKeyExchange·CCS·Finished, 서버의 CCS·Finished 순서 뒤 클라이언트가 암호화 HTTP 요청을 보냅니다. 정상 인증서 기반 ECDHE 전체 핸드셰이크이며 요청까지 TLS 2 RTT, 별표는 암호화된 메시지입니다. |
| 관계 설명 | 시간은 위에서 아래로 흐릅니다. ClientHello와 key_share를 보낸 뒤 서버는 ServerHello와 key_share, 암호화한 EncryptedExtensions·Certificate·CertificateVerify·Finished를 반환합니다. 클라이언트는 서버 인증과 Finished 검증 뒤 자기 Finished와 암호화 HTTP 요청을 이어 보냅니다. 정상 인증서 기반 ECDHE이며 요청까지 TLS 1 RTT입니다. EncryptedExtensions 이름은 화면에서 두 줄로 배치했습니다. 별표는 암호화된 메시지입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| ClientHello 두 버전에서 클라이언트의 첫 메시지를 보냅니다. |
|
|
| 서버 응답 서버의 키 교환과 인증 정보를 받습니다. |
|
|
| 클라이언트 완료 클라이언트의 완료 메시지를 보냅니다. |
|
|
| 1.2 서버 완료 1.2의 서버 CCS·Finished를 확인합니다. |
|
|
TLS 1.2의 전체 핸드셰이크는 두 번 왕복합니다. 클라이언트는 서버의 인증서와 키 교환 값을 받은 뒤에야 자기 키 교환 값을 보내고, 서버의 Finished까지 받아야 요청을 시작할 수 있기 때문입니다. TCP의 핸드셰이크까지 더하면 RTT를 단위로 다음과 같습니다.
| 연결 방식 | 첫 HTTP 요청을 보내기까지 | RTT 40ms일 때 | RTT 150ms일 때 |
|---|---|---|---|
| TCP + TLS 1.2 전체 핸드셰이크 | 3 RTT | 120ms | 450ms |
| TCP + TLS 1.3 전체 핸드셰이크 | 2 RTT | 80ms | 300ms |
| TCP + TLS 1.3, HelloRetryRequest 발생 | 3 RTT | 120ms | 450ms |
| TCP + TLS 1.3 재개, 0-RTT | 1 RTT | 40ms | 150ms |
HelloRetryRequest는 클라이언트가 미리 보낸 키 교환 값의 그룹을 서버가 지원하지 않을 때 "이 그룹으로 다시 보내라"고 요청하는 메시지로, 왕복이 한 번 늘어납니다. 0-RTT는 예전에 접속한 서버에 다시 연결할 때 TCP 핸드셰이크 직후 첫 요청을 바로 싣는 방식이지만, 7장 3편에서 본 것처럼 재전송 공격에 안전한 요청에만 써야 합니다. 이 표는 핸드셰이크만 센 것이어서, DNS 조회와 서버의 처리, 응답이 돌아오는 시간은 따로 더해야 합니다.
TLS 1.2와 무엇이 달라졌나
TLS 1.3은 왕복 수만 줄인 것이 아니라 오랫동안 공격의 빌미가 되었던 기능을 걷어 냈습니다. 서버의 RSA 공개 키로 키를 암호화해 보내는 정적 RSA 키 교환은 전방 비밀성이 없어 없어졌고, 키 교환은 언제나 ECDHE 같은 임시 키로만 합니다. CBC 모드의 암호와 압축, 연결 중간에 다시 협상하는 기능도 여러 공격을 낳아 빠졌고, 데이터는 AEAD로만 보호합니다. 서버의 인증서도 ServerHello 뒤에 암호화되어 오므로, 경로 위에서는 어떤 인증서를 주고받았는지 볼 수 없습니다.
더 깊이: TLS 1.3이 TLS 1.2처럼 보이는 이유
패킷 캡처로 TLS 1.3의 ClientHello를 열어 보면 버전 칸에 TLS 1.2의 번호 0x0303이 적혀 있습니다. 2026년 7월에 RFC 8446을 대체한 TLS 1.3의 현행 명세 RFC 9846에 따르면 예전에는 이 칸으로 버전을 협상했는데, 자기보다 높은 번호를 보면 받아들일 수 있는 ClientHello까지 거절하는 서버가 많았습니다. 이를 버전 불관용(version intolerance)이라고 합니다. 그래서 TLS 1.3은 이 칸을 0x0303으로 고정하고, 실제로 지원하는 버전은 supported_versions 확장에 적어 보냅니다.
중간 장비도 문제였습니다. RFC는 실제 측정에서 상당수의 중간 장비가 TLS 1.3 핸드셰이크를 만나면 오동작했다고 적고, 핸드셰이크를 TLS 1.2의 세션 재개처럼 보이게 하는 호환 모드(middlebox compatibility mode)를 둡니다. 클라이언트는 쓸 일이 없어도 비어 있지 않은 세션 ID를 보내고, 양쪽은 TLS 1.3에서는 아무 뜻이 없는 가짜 ChangeCipherSpec 레코드를 하나씩 보냅니다. TLS 1.3 연결의 캡처에 ChangeCipherSpec이 보이는 것이 이 때문입니다.
버전 협상을 확장으로 옮긴 만큼, 공격자가 협상을 가로채 연결을 낮은 버전으로 끌어내리는 공격에 대한 대비도 들어 있습니다. TLS 1.3을 지원하는 서버가 TLS 1.2로 협상할 때는 ServerHello의 난수 마지막 8바이트를 ASCII로 DOWNGRD인 7바이트와 01로 채우고, 그보다 낮은 버전이면 마지막 바이트를 00으로 채웁니다. TLS 1.3 클라이언트는 낮은 버전의 ServerHello에서 이 값을 보면 핸드셰이크를 중단합니다.
암호 조합의 이름 읽기
TLS 1.2의 암호 조합 이름에는 키 교환, 인증, 데이터 암호, 해시가 모두 들어 있었습니다. TLS 1.3은 키 교환과 인증을 확장으로 따로 협상하므로 이름에는 데이터 암호와 해시만 남았습니다. 다음 코드는 두 형식의 이름을 나누어 읽습니다.
def parse_suite(name):
parts = name.removeprefix("TLS_").split("_")
if "WITH" in parts: # TLS 1.2 형식
i = parts.index("WITH")
kx_auth, cipher = parts[:i], parts[i + 1:]
return {"키 교환": kx_auth[0], "인증": kx_auth[-1], "암호": "_".join(cipher[:-1]), "해시": cipher[-1]}
return {"키 교환": "따로 협상", "인증": "따로 협상", "암호": "_".join(parts[:-1]), "해시": parts[-1]}
for suite in ["TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
"TLS_RSA_WITH_AES_128_CBC_SHA",
"TLS_AES_256_GCM_SHA384"]:
print(suite, parse_suite(suite))TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 {'키 교환': 'ECDHE', '인증': 'RSA', '암호': 'AES_128_GCM', '해시': 'SHA256'}
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 {'키 교환': 'ECDHE', '인증': 'ECDSA', '암호': 'CHACHA20_POLY1305', '해시': 'SHA256'}
TLS_RSA_WITH_AES_128_CBC_SHA {'키 교환': 'RSA', '인증': 'RSA', '암호': 'AES_128_CBC', '해시': 'SHA'}
TLS_AES_256_GCM_SHA384 {'키 교환': '따로 협상', '인증': '따로 협상', '암호': 'AES_256_GCM', '해시': 'SHA384'}첫 이름은 ECDHE로 키를 교환하고, RSA 인증서의 키로 서버의 서명을 확인하며, AES-128-GCM으로 데이터를 보호하고, SHA-256을 키 유도와 확인 값에 쓴다는 뜻입니다. 세 번째 이름처럼 키 교환 자리에 RSA만 있는 조합은 정적 RSA 키 교환이라 전방 비밀성이 없고 CBC 모드까지 쓰므로, 지금의 서버 설정에서는 빼야 합니다. 마지막의 TLS 1.3 이름만 보고는 서버의 인증서가 RSA인지 ECDSA인지 알 수 없습니다. 이름 안의 숫자도 구별해야 해서, AES_256의 256은 키의 비트 수이고 SHA384의 384는 해시 출력의 비트 수입니다.
인증서의 이름이 맞는가
서버가 보낸 X.509 인증서는 "이 공개 키는 이 이름의 서비스의 것"이라는 CA의 서명입니다. 브라우저는 인증서가 있다는 것만 보지 않고, 접속한 도메인 이름이 인증서의 주체 대체 이름(subject alternative name, SAN)에 들어 있는지를 먼저 확인합니다(RFC 9525). 예전에는 인증서의 Common Name(CN)을 보았지만 지금 브라우저는 SAN만 봅니다. SAN에는 *.example.com 같은 와일드카드를 쓸 수 있는데, 와일드카드는 맨 왼쪽 label 하나만 대신합니다.
def matches(host, san):
host, san = host.lower(), san.lower() # DNS 이름은 대소문자를 구별하지 않는다
if san.startswith("*."):
labels = host.split(".")
return len(labels) > 2 and ".".join(labels[1:]) == san[2:] # 와일드카드는 맨 왼쪽 label 하나만 대신한다
return host == san
sans = ["example.com", "*.example.com"]
for host in ["example.com", "WWW.example.com", "a.b.example.com", "example.org"]:
print(host, any(matches(host, san) for san in sans))example.com True
WWW.example.com True
a.b.example.com False
example.org False*.example.com은 www.example.com은 대신하지만 두 단계 아래의 a.b.example.com과 example.com 자신은 대신하지 못합니다. 그래서 example.com과 그 하위 도메인을 모두 한 인증서로 받으려면 두 이름을 함께 SAN에 넣습니다. 이름 다음으로는 인증서의 유효 기간 안인지, 서버 인증 용도로 발급되었는지를 확인합니다. Python의 ssl.create_default_context()처럼 표준 라이브러리의 기본 설정은 이 모든 검사를 켜 둡니다. 개발 중의 인증서 오류를 피하려고 검사를 끄는 코드가 운영 환경까지 넘어가면 중간자 공격을 막을 수 없게 되므로, 검사를 끄는 대신 시험용 CA를 신뢰 목록에 넣는 방법을 씁니다.
인증서 사슬과 신뢰 앵커
서버 인증서에 서명한 것은 보통 루트 CA가 아니라 중간 CA(intermediate CA)이고, 중간 CA의 인증서에는 루트 CA가 서명합니다. 브라우저는 서버 인증서에서 시작해 서명한 쪽을 따라 올라가, 운영체제나 브라우저의 신뢰 저장소에 미리 들어 있는 루트 인증서, 곧 신뢰 앵커(trust anchor)에 닿는지를 확인합니다.
인증서와 신뢰
CA가 서명해도, 클라이언트의 신뢰 앵커가 필요합니다
화살표는 CA의 서명 방향입니다. 클라이언트는 서버 인증서에서 로컬 신뢰 앵커까지 검증 경로를 구성하며, 서버가 보낸 루트 인증서만으로 신뢰하지 않습니다.
서버가 보낸 루트 인증서라는 이유만으로 신뢰하지 않습니다. 이 그림은 CA 서명 관계이며, 접속 이름·유효기간·용도 검증과 서버의 개인키 소유 증명은 별도로 필요합니다.
서버는 핸드셰이크에서 자기 인증서와 중간 CA 인증서를 보냅니다. 루트 인증서는 보낼 필요가 없고, 보내더라도 클라이언트는 그것을 근거로 믿지 않습니다. 믿을지는 클라이언트가 이미 가진 신뢰 저장소가 정하기 때문입니다. 반대로 서버가 중간 CA 인증서를 빠뜨리면 사슬이 끊겨, 중간 인증서를 따로 찾아오는 일부 브라우저에서는 되고 다른 클라이언트에서는 실패하는, 원인을 찾기 어려운 장애가 생깁니다.
루트 CA가 서버 인증서에 직접 서명하지 않는 것은 사고를 가두기 위해서입니다. 루트의 개인 키는 인터넷에 연결하지 않은 장비에 보관하고 중간 CA를 만들 때만 꺼내며, 매일의 발급은 중간 CA가 맡습니다. 중간 CA의 키가 유출되면 그 중간 CA만 폐기하고 새로 만들면 되지만, 루트의 키가 유출되면 그 루트를 믿는 모든 기기에서 루트를 빼야 하는 큰 사고가 됩니다.
openssl로 확인하기
서버의 TLS 설정은 openssl s_client로 확인할 수 있습니다. -servername으로 SNI를 보내야 한 IP에서 여러 도메인을 운영하는 서버가 맞는 인증서를 돌려줍니다.
openssl s_client -connect example.com:443 -servername example.com \
-verify_hostname example.com -verify_return_error </dev/null
openssl s_client -connect example.com:443 -servername example.com -tls1_3 </dev/null
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null첫 명령은 인증서의 이름까지 검사하고, 검증에 실패하면 연결을 끊습니다. s_client는 기본으로 인증서 오류가 있어도 연결을 계속하므로, 이 두 옵션 없이 연결되었다고 해서 인증서가 올바르다는 뜻은 아닙니다. 출력에서는 Protocol과 Cipher 줄로 협상된 버전과 암호 조합을, Verify return code: 0 (ok)로 검증의 성공을 확인합니다. 세 번째 명령은 서버가 보낸 인증서들을 모두 보여 주므로, 중간 CA 인증서가 빠지지 않았는지 볼 때 씁니다. 인증서 하나의 내용은 출력을 openssl x509 -noout -subject -issuer -dates -ext subjectAltName에 넘겨 주체와 발급자, 유효 기간, SAN을 읽습니다.
정리
- TLS 핸드셰이크는 버전과 암호 조합을 고르고, 인증서와 서명으로 서버를 확인하며, ECDHE로 이번 연결의 키를 만듭니다.
- TLS 1.3은 ClientHello에 키 교환 값을 미리 실어 한 번의 왕복으로 끝나고, ServerHello 뒤의 메시지를 모두 암호화하며, CertificateVerify의 서명으로 중간자 공격을 막습니다.
- TCP를 더하면 TLS 1.2는 3 RTT, TLS 1.3은 2 RTT가 지나야 첫 요청을 보낼 수 있으며, HelloRetryRequest는 왕복을 하나 더합니다.
- TLS 1.3은 정적 RSA 키 교환, CBC, 압축, 재협상을 없앴고, 암호 조합의 이름에는 데이터 암호와 해시만 담습니다.
- 브라우저는 접속한 이름이 인증서의 SAN에 있는지 확인하며, 와일드카드는 맨 왼쪽 label 하나만 대신합니다.
- 인증서 사슬은 서버 인증서에서 중간 CA를 거쳐 클라이언트가 가진 신뢰 앵커까지 이어져야 하고, 서버가 중간 CA 인증서를 빠뜨리면 일부 클라이언트에서만 실패합니다.
연습 문제
1. 왕복이 하나 더 생기면
RTT가 60ms인 경로에서 새 TCP 연결 위에 TLS 1.3으로 접속했는데, HelloRetryRequest가 일어나 핸드셰이크의 왕복이 한 번 더 필요했습니다. DNS 조회와 서버의 처리 시간을 빼면 첫 HTTP 요청을 보내기까지 얼마나 걸리나요? 이 결과를 TLS 1.2와 비교해 보세요.
해설 보기
TCP의 1 RTT, TLS 1.3의 1 RTT, HelloRetryRequest의 1 RTT를 더해 3 × 60 = 180ms입니다. TLS 1.2의 전체 핸드셰이크도 TCP를 더하면 3 RTT이므로 이 경우에는 같아집니다. 클라이언트가 처음부터 서버가 지원하는 그룹, 예를 들어 X25519로 키 교환 값을 보내면 HelloRetryRequest를 피할 수 있으므로, 서버와 클라이언트가 지원하는 그룹을 맞춰 두는 것이 첫 접속을 빠르게 하는 방법입니다.
2. 이름 안의 숫자
TLS_AES_256_GCM_SHA384로 협상했다면 AES 키의 길이와 해시 출력의 길이는 각각 얼마인가요? 이 이름만으로 서버 인증서가 RSA인지 ECDSA인지 알 수 있나요?
해설 보기
AES의 키는 256비트이고, SHA-384의 출력은 384비트입니다. 해시의 숫자를 암호 키의 길이로 읽으면 안 됩니다. TLS 1.3의 암호 조합 이름에는 인증 방식이 들어 있지 않으므로 인증서의 종류는 알 수 없습니다. 인증서가 RSA인지 ECDSA인지는 서버가 보낸 인증서를 openssl x509로 읽거나, 협상된 서명 알고리즘을 따로 확인해야 합니다.
3. 와일드카드 인증서와 빠진 중간 인증서
어느 서버가 SAN에 *.example.com만 든 인증서를 쓰고 있습니다. api.example.com, example.com, v2.api.example.com으로 접속하면 각각 이름 검사를 통과하나요? 또 이 서버가 중간 CA 인증서를 보내지 않는다면 어떤 증상이 나타날 수 있나요?
해설 보기
api.example.com은 맨 왼쪽 label 하나를 와일드카드가 대신하므로 통과합니다. example.com은 대신할 label이 없으므로, v2.api.example.com은 와일드카드가 두 label을 대신해야 하므로 통과하지 못합니다. 이 둘이 필요하다면 example.com과 *.api.example.com을 SAN에 더해야 합니다. 중간 CA 인증서가 빠지면 사슬을 신뢰 앵커까지 잇지 못하므로, 중간 인증서를 캐시해 두었거나 따로 내려받는 브라우저에서는 접속되고, 그렇지 않은 모바일 앱이나 서버 간 통신에서는 인증서 오류가 나는 식으로 클라이언트마다 결과가 달라집니다. openssl s_client -showcerts로 서버가 보내는 인증서의 수를 확인하면 바로 알 수 있습니다.
다음 글 인증서 실무에서는 인증서를 발급받는 검증 방식과 ACME를 이용한 자동 갱신, 배포와 폐기에서 자주 나는 문제를 살펴봅니다.