브라우저가 HTTP 요청의 첫 바이트를 보내기 전에, 서버와 작은 의식을 치러야 한다. 그 의식이 핸드셰이크(handshake)다. 말 그대로 ‘악수’인데, 양쪽이 프로토콜 버전과 알고리즘을 맞추고, 서버가 자기 정체를 증명하고, 선로를 엿보는 누구도 계산할 수 없는 키를 양쪽이 각자 만들어 내는 과정이다.

더 이상 대단한 의식은 아니다. TLS 1.3이 그렇게 만들었다. 하지만 여전히 약속과 증명과 키 재료를 주고받는 실제 교환이고, 비용은 네트워크 왕복 한 번이다. 보통 연결이라면 수십 밀리초, 대부분의 개발자가 생각조차 안 할 만큼 빠르다. https://를 치고, 페이지가 그려지는 사이에 핸드셰이크는 묻혀서 보이지 않는다.

사라지면 안 된다. 현대 웹 스택에서 가장 잘 만들어진 엔지니어링 중 하나다.

(이름 이야기 하나. 아직도 ‘SSL 핸드셰이크’라는 말을 많이 쓴다. SSL이라는 이름을 단 마지막 버전은 SSL 3.0이고, 1999년 TLS 1.0으로 대체됐다. 오늘 브라우저가 협상하는 건 전부 TLS 1.2나 1.3이다. 각각 RFC 5246, RFC 8446에 정의돼 있다.)

1단계: ClientHello

클라이언트가 카드를 테이블에 올린다.

ClientHello 메시지에 들어가는 것: 브라우저가 지원하는 TLS 버전, 암호 스위트 목록, 32바이트 랜덤 값, SNI로 알리는 서버 이름, 받아들일 수 있는 키 교환 그룹과 서명 알고리즘, 그리고 가장 중요한 key_share.

TLS 1.3에서 암호 스위트는 암호화와 해시의 조합일 뿐이다(예: TLS_AES_128_GCM_SHA256). 키 교환과 서명은 위의 확장 필드로 따로 협상한다. TLS 1.2에서는 스위트 이름 하나에 이 모두가 묶여 있었고, 그래서 옛 이름들이 그렇게 길다.

key_share가 이전 핸드셰이크와의 큰 속도 차이다. TLS 1.2에서는 서버가 키 교환 방식을 고를 때까지 기다렸다가 자기 키 재료를 보낼 수 있었다. TLS 1.3은 안 기다린다. 클라이언트가 서버가 받아 줄 그룹을 추측하고, 그 그룹용 임시 키 재료를 만들어 미리 넣어 보낸다. 오랫동안 그 추측은 대개 X25519였고, 요즘 Chrome과 Firefox는 여기에 양자내성 하이브리드인 X25519MLKEM768도 함께 보낸다. 추측이 빗나가면 서버가 HelloRetryRequest로 답하고 클라이언트가 다시 보낸다. 왕복이 한 번 늘지만 실제로는 드물다.

이 결정 하나가 TLS 1.3이 더 빠르게 느껴지는 이유다. 프로토콜이 기다리는 예의를 버렸다.

2단계: ServerHello

서버가 클라이언트 제안 중에서 고른다. 암호 스위트를 선택하고, 자기 랜덤 값으로 응답하고, 클라이언트의 키 교환 추측을 받아들이면 자기 key_share를 넣는다.

이 시점에서 양쪽 모두 랜덤 값과 키 공유를 교환했다. 각자 (타원곡선) Diffie-Hellman으로 같은 공유 비밀을 독립적으로 계산할 수 있다. 핸드셰이크 암호화 키가 나온다. 이후 서버가 보내는 모든 것은 — 짧은 EncryptedExtensions 메시지부터 — 암호화된다.

TLS 1.2에서는 같은 협상에 왕복 두 번이 걸렸다. TLS 1.3은 한 번으로 줄였다.

3단계: 서버 인증서

서버가 인증서 체인을 보낸다 — 도메인의 리프 인증서와 중간 인증서. 맨 위의 루트 CA 인증서는 보통 보내지 않는다. 브라우저가 이미 갖고 있기 때문이다. 체인은 서버의 신원 주장이다. “나는 example.com이고, 네가 믿는 기관이 그걸 보증한 서명이 여기 있다.”

브라우저는 루트 스토어 — 엄선된 백몇십 개의 루트 인증서 목록 — 와 대조해 체인을 검사한다. 체인의 어느 고리라도 끊기거나, 만료되거나, 신뢰할 수 없으면 핸드셰이크가 실패한다.

실제로 가장 자주 문제가 생기는 단계다. 만료된 인증서, 빠진 중간 인증서, 호스트명 불일치. 암호학은 괜찮다. PKI 관리가 사람이 실수하는 곳이다.

4단계: CertificateVerify

서버가 인증서의 개인키를 실제로 갖고 있다는 걸 증명한다. 지금까지의 핸드셰이크 기록 전체 — 오간 모든 메시지 — 의 해시에 개인키로 서명한다.

누군가 인증서를 복사해서 서버인 척하는 걸 막는 단계다. 인증서는 공개다. 개인키는 아니다. 진짜 서버만 이 서명을 만들 수 있다.

5단계: Server Finished

서버가 핸드셰이크 기록 전체에 대한 MAC(Message Authentication Code)을 담은 Finished 메시지를 보낸다. 서버가 클라이언트와 같은 메시지를 봤고 전송 중 변조가 없었음을 증명한다.

이 시점에서 서버는 핸드셰이크에 대해 할 말이 끝났다. 애플리케이션 데이터를 받을 준비가 됐다.

6단계: 클라이언트 검증

클라이언트 차례. 루트 스토어와 인증서 체인을 검증한다. 접속한 호스트명이 인증서의 SAN(Subject Alternative Name) 항목 중 하나와 일치하는지 확인한다. CertificateVerify 서명을 인증서의 공개키로 검증한다. Finished MAC이 유효한지 확인한다.

뭐든 실패하면 — 호스트명 불일치, 만료된 인증서, 잘못된 서명, 신뢰할 수 없는 CA — 브라우저가 연결을 끊고 에러 페이지를 보여준다. 부분 신뢰는 없다. 전부 아니면 전무다.

클라이언트는 이때 애플리케이션 트래픽 키도 계산한다. Diffie-Hellman으로 얻은 공유 비밀과 핸드셰이크 기록을 재료로 쓴다.

7단계: Client Finished

클라이언트가 자기 Finished 메시지를 보낸다. 클라이언트 쪽에서 본 핸드셰이크 기록에 대한 또 하나의 MAC이다. 핸드셰이크 완료. 양쪽 모두 공유 비밀에서 유도한 동일한 대칭 키를 갖게 됐다.

애플리케이션 데이터가 흐른다. HTTP 요청 — GET, POST, 헤더, 쿠키 — 이 암호화된 채널로 간다. 왕복 한 번 걸린 보이지 않는 협상이 끝났다.

비교: TLS 1.2 핸드셰이크

아직 TLS 1.2로 말하는 서버가 많고, 패킷 캡처를 열면 이 모양을 만나게 되니 옛 순서도 알아 두면 좋다.

  1. ClientHello — 버전, 암호 스위트, 랜덤 값. 키 공유는 없다.
  2. ServerHello, Certificate, ServerKeyExchange, ServerHelloDone — 서버가 스위트를 고르고, 인증서를 평문으로 보내고, (ECDHE 스위트라면) 개인키로 서명한 Diffie-Hellman 파라미터를 보낸다.
  3. ClientKeyExchange, ChangeCipherSpec, Finished — 클라이언트가 키 교환의 자기 몫을 보내고, 암호화로 전환한 뒤 Finished를 보낸다.
  4. ChangeCipherSpec, Finished — 서버도 전환하고 자기 Finished를 보낸다.

첫 HTTP 요청이 나가기까지 왕복 두 번이다. 속도보다 더 중요한 차이가 셋 있다. TLS 1.2에서는 인증서가 암호화되지 않은 채 오가서, 경로 위의 누구나 어떤 인증서를 받았는지 읽을 수 있다. TLS 1.2는 RSA 키 교환도 허용했다. 클라이언트가 비밀을 서버 인증서 키로 암호화해 보내는 방식이라, 트래픽을 녹화해 뒀다가 몇 년 뒤 그 키를 훔치면 전부 풀린다. TLS 1.3은 RSA 키 교환을 아예 없앴고, 그래서 모든 TLS 1.3 연결에는 전방 보안(forward secrecy)이 있다. 그리고 TLS 1.3은 10년 동안 공격을 낳아 온 낡은 선택지들(CBC 모드 스위트, RC4, 재협상, 압축)을 통째로 버렸다.

왜 TLS 1.3이 더 빠른가

TLS 1.2는 애플리케이션 데이터가 흐르기 전에 왕복 두 번이 필요했다. TLS 1.3은 한 번. ClientHello에 미리 넣는 키 공유가 트릭이다. 서버가 받을 그룹을 추측해서 왕복 한 번을 아낀다.

다시 접속할 때 쓰는 0-RTT 모드도 있다. 클라이언트가 이전 세션에서 받은 사전 공유 키(세션 티켓으로 전달된다)를 갖고 있다가 첫 메시지에 애플리케이션 데이터를 실어 보낸다. 서버는 그걸 바로 처리할 수 있다. 데이터가 흐르기 전 왕복이 0번이다.

주의점: 0-RTT 데이터는 재전송(replay)될 수 있다. 첫 메시지를 잡은 공격자가 그대로 다시 보낼 수 있다. 그래서 0-RTT에는 여러 번 실행돼도 결과가 같은 요청 — GET이지 POST가 아니다 — 만 실어야 한다. 속도에는 대가가 있고, 모두가 쓸 기능은 아니다.

뭐가 잘못되나

각 단계에서 깨질 수 있다. 인증서 만료 — 가장 흔한 실패, 대개 갱신을 까먹어서. 호스트명 불일치 — 인증서는 www.example.com인데 example.com으로 접속했다. 빠진 중간 인증서 — 서버가 리프만 보내서 클라이언트가 체인을 못 만든다. 시계 오차 — 클라이언트 시계가 틀려서 멀쩡한 인증서가 만료된 것처럼 보인다. 공통 파라미터 없음 — 서버와 클라이언트가 함께 쓸 수 있는 버전이나 암호 스위트가 없다. 대개 한쪽이 낡아서다.

대부분의 TLS 실패는 암호학 문제가 아니다. 운영 문제다. 수학은 작동한다. 인증서를 관리하는 사람이 약한 고리다.