TLS 핸드셰이크를 오래 "클라이언트와 서버가 세션 키를 교환하는 과정"이라고 뭉뚱그려 알고 있었다. 그런데 막상 RFC 8446을 따라가 보니, (EC)DHE로 실제 합의하는 값은 원시 공유 비밀 딱 하나였다. 방향별(client/server)·단계별(handshake/application)·용도별(exporter/resumption) 키가 모두 다른데, 이 키들이 전부 그 비밀 하나에서 나온다면 대체 어떻게 갈라지는 걸까?
또 하나 걸렸던 게 있다. TLS 1.2는 인증서를 평문으로 주고받은 뒤에야 암호화로 넘어갔는데, TLS 1.3은 ServerHello 직후부터 — 인증서조차 — 암호문이다. 아직 클라이언트 인증도 끝나지 않은 시점에 이게 어떻게 가능한가. 답은 전부 키 스케줄(key schedule) 에 있었다. 이 글은 RFC 8446 §7.1을 위에서 아래로 따라가며 그 사슬을 정리한 기록이다.
키 스케줄의 재료는 HKDF(RFC 5869)다. 두 함수로 나뉜다.
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 자리에 그 시점까지 오간 핸드셰이크 메시지 전체의 해시를 넣는다. 그래서 같은 상위 비밀이라도 "어디까지 봤느냐"에 따라 다른 키가 나오고, 중간자가 메시지를 한 바이트라도 바꾸면 양쪽의 파생 키부터 어긋난다.
이 한 줄이 뒤에 나올 모든 성질(도메인 분리, 무결성 바인딩)의 뿌리다.
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
읽는 법을 정리하면:
HKDF-Extract(salt=0, IKM=PSK). PSK가 없으면 IKM은 해시 길이만큼의 0으로 채운다. 0-RTT용 client_early_traffic_secret가 여기서 나온다.Derive-Secret(., "derived", "")로 한 번 더 펼친 값을 쓴다. 이 "derived" 단계가 단계 간 도메인 분리를 강제해서, 한 단계 키가 새더라도 인접 단계로 역산이 안 되게 만든다.HKDF-Extract(salt=derived(Early), IKM=(EC)DHE). 여기서 나온 [c|s] hs traffic가 ServerHello 직후부터 EncryptedExtensions·Certificate·Finished를 암호화한다. 1장에서 걸렸던 "인증서가 왜 암호문이냐"의 답이 정확히 이 지점이다.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까지 바인딩된다. 뒤로 갈수록 더 많은 메시지에 묶인다.
여기서 흔한 오해 하나. 트래픽 "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 하나로 무결성과 상대 인증을 겸하는 셈이다.
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는 멱등한 요청에만 쓰는 게 안전하다고 알려져 있다.
"derived" 단계가 단계 간 도메인 분리를, 트랜스크립트 해시가 키-메시지 바인딩을 만든다. 변조되면 MAC이 아니라 파생 키부터 어긋난다."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).