TLS 1.3은 (EC)DHE 비밀 하나에서 어떻게 모든 키를 뽑는가 — HKDF 키 스케줄

seonwoo_jung·2026년 7월 21일

1. 도입 — "세션 키를 교환한다"는 말이 감췄던 것

TLS 핸드셰이크를 오래 "클라이언트와 서버가 세션 키를 교환하는 과정"이라고 뭉뚱그려 알고 있었다. 그런데 막상 RFC 8446을 따라가 보니, (EC)DHE로 실제 합의하는 값은 원시 공유 비밀 딱 하나였다. 방향별(client/server)·단계별(handshake/application)·용도별(exporter/resumption) 키가 모두 다른데, 이 키들이 전부 그 비밀 하나에서 나온다면 대체 어떻게 갈라지는 걸까?

또 하나 걸렸던 게 있다. TLS 1.2는 인증서를 평문으로 주고받은 뒤에야 암호화로 넘어갔는데, TLS 1.3은 ServerHello 직후부터 — 인증서조차 — 암호문이다. 아직 클라이언트 인증도 끝나지 않은 시점에 이게 어떻게 가능한가. 답은 전부 키 스케줄(key schedule) 에 있었다. 이 글은 RFC 8446 §7.1을 위에서 아래로 따라가며 그 사슬을 정리한 기록이다.

2. 핵심 개념 — HKDF Extract와 Expand

키 스케줄의 재료는 HKDF(RFC 5869)다. 두 함수로 나뉜다.

  • Extract: 엔트로피가 고르게 퍼지지 않은 입력 키 재료(IKM)를 salt로 흡수해, 고정 길이의 유사난수 키(PRK)로 만든다. "raw한 비밀을 균일한 키 재료로 정제"하는 단계다.
  • Expand: 그 PRK를 원하는 길이·용도로 펼친다.

TLS 1.3은 Expand를 라벨로 감싼 HKDF-Expand-Label을 쓴다. 여기서 결정적인 함수가 Derive-Secret이다.

HKDF-Expand-Label(Secret, Label, Context, Length) =
    HKDF-Expand(Secret, HkdfLabel, Length)
    // HkdfLabel.label = "tls13 " + Label  (모든 라벨에 "tls13 " 접두)

Derive-Secret(Secret, Label, Messages) =
    HKDF-Expand-Label(Secret, Label,
                      Transcript-Hash(Messages),   // ← Context 자리에 트랜스크립트 해시
                      Hash.length)

Derive-Secret은 Context 자리에 그 시점까지 오간 핸드셰이크 메시지 전체의 해시를 넣는다. 그래서 같은 상위 비밀이라도 "어디까지 봤느냐"에 따라 다른 키가 나오고, 중간자가 메시지를 한 바이트라도 바꾸면 양쪽의 파생 키부터 어긋난다.

이 한 줄이 뒤에 나올 모든 성질(도메인 분리, 무결성 바인딩)의 뿌리다.

3. 내부 동작 — 3단 Extract 사슬

RFC 8446 §7.1의 다이어그램을 옮기면 이렇게 생겼다. Extract가 세 번 쌓인다.

             0
             |
   PSK ->  HKDF-Extract = Early Secret
             |  +-> Derive-Secret(., "c e traffic", CH) = client_early_traffic_secret
             v
       Derive-Secret(., "derived", "")
             |
  (EC)DHE -> HKDF-Extract = Handshake Secret
             |  +-> Derive-Secret(., "c hs traffic", CH..SH) = client_handshake_traffic_secret
             |  +-> Derive-Secret(., "s hs traffic", CH..SH) = server_handshake_traffic_secret
             v
       Derive-Secret(., "derived", "")
             |
       0  -> HKDF-Extract = Master Secret
             |  +-> Derive-Secret(., "c ap traffic", CH..server Fin) = client_application_traffic_secret_0
             |  +-> Derive-Secret(., "s ap traffic", CH..server Fin) = server_application_traffic_secret_0
             +-> Derive-Secret(., "res master",  CH..client Fin) = resumption_master_secret

읽는 법을 정리하면:

  • Early Secret = HKDF-Extract(salt=0, IKM=PSK). PSK가 없으면 IKM은 해시 길이만큼의 0으로 채운다. 0-RTT용 client_early_traffic_secret가 여기서 나온다.
  • 다음 Extract의 salt는 앞 단계 비밀을 그대로 쓰지 않는다. Derive-Secret(., "derived", "")로 한 번 더 펼친 값을 쓴다. 이 "derived" 단계가 단계 간 도메인 분리를 강제해서, 한 단계 키가 새더라도 인접 단계로 역산이 안 되게 만든다.
  • Handshake Secret = HKDF-Extract(salt=derived(Early), IKM=(EC)DHE). 여기서 나온 [c|s] hs traffic가 ServerHello 직후부터 EncryptedExtensions·Certificate·Finished를 암호화한다. 1장에서 걸렸던 "인증서가 왜 암호문이냐"의 답이 정확히 이 지점이다.
  • Master Secret = HKDF-Extract(salt=derived(Handshake), IKM=0). 실제 데이터용 [c|s] ap traffic가 여기서 나온다.

가장 헷갈렸던 두 곳: Master의 IKM이 (EC)DHE가 아니라 0이라는 점(엔트로피는 이미 Handshake 단계의 salt로 흡수됐다), 그리고 Early의 salt가 0이라는 점. 원문에서 두 번 확인했다.

또 한 가지, 트랜스크립트 범위가 비밀마다 다르다. hs traffic은 CH..SH까지, ap traffic은 CH..서버 Finished까지, res master는 CH..클라이언트 Finished까지 바인딩된다. 뒤로 갈수록 더 많은 메시지에 묶인다.

4. 트래픽 키·nonce·Finished

여기서 흔한 오해 하나. 트래픽 "secret"은 그 자체로 AEAD 키가 아니다. 레코드 보호용 key/iv를 한 겹 더 펼쳐야 한다(§7.3, §5.3).

[sender]_write_key = HKDF-Expand-Label(Secret, "key", "", key_length)
[sender]_write_iv  = HKDF-Expand-Label(Secret, "iv",  "", iv_length)

# 레코드마다의 nonce는 새로 뽑지 않고, write_iv를 시퀀스 번호와 XOR
nonce_n = write_iv XOR seq_n     # seq는 키가 바뀌면 0으로 리셋

핸드셰이크 무결성의 마지막 잠금은 Finished 메시지다(§4.4.4). MAC 키를 해당 방향 handshake traffic secret에서 파생하고, 그걸로 트랜스크립트 해시에 HMAC을 건다.

finished_key = HKDF-Expand-Label(BaseKey, "finished", "", Hash.length)
verify_data  = HMAC(finished_key, Transcript-Hash(... CertificateVerify까지))

서버 Finished가 검증되면 클라이언트는 "지금까지의 모든 메시지가 위조되지 않았다"와 "상대가 (EC)DHE 비밀을 실제로 안다"를 동시에 확인한다. MAC 하나로 무결성과 상대 인증을 겸하는 셈이다.

5. 1-RTT·0-RTT 흐름과 트레이드오프

Client                                Server
ClientHello + key_share  ------->
                                 ServerHello + key_share   (Handshake Secret 확정)
                            {EncryptedExtensions}
                            {Certificate}{CertificateVerify}{Finished}
                         <------- [Application Data 가능]
{Finished}               ------->
[Application Data]       <------> [Application Data]

{...}는 handshake traffic 키로, [...]는 application traffic 키로 보호된다. 0-RTT는 이전 세션의 resumption_master_secret로 만든 PSK를 재사용해, ClientHello와 함께 client_early_traffic_secret로 암호화한 데이터를 첫 왕복에 실어 보낸다.

다만 RFC 8446이 명시적으로 경고하는 트레이드오프가 있다. 이 early 데이터는 (EC)DHE 교환 이전에 나가므로 순방향 비밀성(forward secrecy)이 없고, 서버가 재생(replay)을 프로토콜 차원에서 막아 주지 못한다. 그래서 0-RTT는 멱등한 요청에만 쓰는 게 안전하다고 알려져 있다.

6. 정리

  • (EC)DHE로 합의하는 건 원시 비밀 하나뿐이다. 방향·단계·용도별 키는 전부 HKDF로 그 비밀에서 파생된다.
  • "derived" 단계가 단계 간 도메인 분리를, 트랜스크립트 해시가 키-메시지 바인딩을 만든다. 변조되면 MAC이 아니라 파생 키부터 어긋난다.
  • 트래픽 secret ≠ 암호화 키. "key"/"iv" 라벨로 한 겹 더 펼쳐야 실제 AEAD 키가 된다.

더 파고들 만한 것: Key Update(§7.2)의 "traffic upd" 라벨이 주는 forward secrecy 효과, 그리고 PSK binder("ext binder"/"res binder")가 0-RTT ClientHello를 인증하고 재생을 억제하는 방식(single-use ticket, freshness window).

참고 자료

  • RFC 8446 (The TLS 1.3 Protocol) §7.1 Key Schedule, §4.4.4 Finished, §5.3 Per-Record Nonce, §7.2 Key Update, §2/§2.3 핸드셰이크·0-RTT 개요
  • RFC 5869 (HKDF — Extract-then-Expand Key Derivation Function)

0개의 댓글