TLS 1.3을 공부하다 보면 흔히 다음과 같은 설명을 접하게 된다.
클라이언트와 서버가 키 교환을 수행한 뒤, 그 키를 이용해 통신을 암호화한다.
틀린 설명은 아니다. 하지만 실제 TLS 1.3은 단순히 Diffie-Hellman으로 만들어진 하나의 shared secret을 그대로 AES 키로 사용하는 구조가 아니다.
TLS 1.3에는 꽤 정교한 Key Schedule이 존재한다.
하나의 비밀값에서 다음과 같이 목적이 서로 다른 여러 Secret과 Key를 단계적으로 파생한다.
PSK
│
▼
Early Secret
│
├─ Binder Key
├─ Early Traffic Secret
│
▼
Asymmetric Shared Secret
│
▼
Handshake Secret
│
├─ Client Handshake Traffic Secret
├─ Server Handshake Traffic Secret
│
▼
Main Secret
│
├─ Client Application Traffic Secret
├─ Server Application Traffic Secret
├─ Exporter Secret
└─ Resumption Secret
그리고 여기서 끝나는 것도 아니다.
예를 들어
client_application_traffic_secret_0
이라는 값 자체가 AES 키인 것도 아니다.
다시 여기서
write_key
write_iv
를 따로 파생한 뒤 실제 TLS Record 암호화에 사용한다.
즉 TLS 1.3의 핵심은 단순한 Key Exchange가 아니라,
Key Exchange
↓
Key Derivation
↓
Context Binding
↓
Key Separation
↓
Traffic Key 생성
이라는 계층적 구조라고 볼 수 있다.
이 글에서는 TLS 1.3 Key Schedule을 최상단부터 실제 Record 암호화 키가 만들어지는 지점까지 따라가 본다.
1. 기준 RFC
TLS 1.3은 원래 RFC 8446에서 정의되었다.
하지만 현재 기본 규격은 RFC 9846이며 RFC 8446을 대체한다.
RFC 9846은 TLS 버전 번호 자체는 그대로 1.3을 유지하면서 RFC 8446의 모호한 부분과 일부 요구사항을 수정했다.
Key Schedule의 핵심 암호학적 구조는 그대로 유지된다.
다만 중요한 용어 변경이 있다.
RFC 8446의
Master Secret
resumption_master_secret
exporter_master_secret
등은 최신 RFC에서 각각
Main Secret
resumption_secret
exporter_secret
이라는 표현을 사용한다.
그런데 HKDF 내부의 label은 기존 구현과의 호환성을 위해 여전히
"exp master"
"res master"
를 사용한다.
따라서 이 글에서는 최신 RFC의 명칭인 Main Secret을 사용한다.
2. TLS 1.3 Key Schedule의 핵심: HKDF
TLS 1.3 Key Schedule을 이해하려면 먼저 HKDF를 이해해야 한다.
HKDF는 RFC 5869에서 정의된 HMAC 기반 Key Derivation Function이다.
HKDF에는 크게 두 단계가 있다.
HKDF-Extract
HKDF-Expand
간단히 말하면:
Extract
↓
입력 비밀값을 균일한 pseudorandom key로 정리
Expand
↓
그 pseudorandom key에서 목적별 새로운 key를 생성
TLS 1.3은 이 구조를 적극적으로 이용한다.
3. HKDF-Extract
기본 형태는 다음과 같다.
PRK = HKDF-Extract(salt, IKM)
내부적으로는 사실상 다음 HMAC 계산이다.
PRK = HMAC-Hash(salt, IKM)
여기서:
salt = 현재 Key Schedule 상태
IKM = Input Keying Material
= 새롭게 추가할 비밀값
이라고 생각하면 TLS 1.3 Key Schedule을 이해하기 쉽다.
예를 들어 나중에 등장하는 Handshake Secret은 개념적으로
이전 Key Schedule 상태
+
새로운 Diffie-Hellman shared secret
을 HKDF-Extract로 결합해서 만들어진다.
이 점이 상당히 중요하다.
TLS 1.3은 단순히
SHA256(shared_secret)
같은 방식으로 키를 만드는 것이 아니다.
기존의 secret state와 새로운 entropy를 HKDF를 통해 결합한다.
4. HKDF-Expand
HKDF-Extract에서 만든 PRK로부터 목적별 비밀값을 생성하는 것이 HKDF-Expand다.
개념적으로:
HKDF-Expand(PRK, info, Length)
형태다.
여기서 info를 다르게 하면 같은 PRK에서도 서로 다른 출력이 만들어진다.
예를 들어 개념적으로:
HKDF-Expand(secret, "client key", ...)
HKDF-Expand(secret, "server key", ...)
는 서로 다른 값을 만든다.
이 특성이 TLS에서 매우 중요하다.
하나의 Secret으로부터 여러 키를 파생하면서도 각각을 논리적으로 분리할 수 있기 때문이다.
이를 Domain Separation / Key Separation 관점에서 이해할 수 있다.
5. TLS는 HKDF-Expand를 그대로 사용하지 않는다
TLS 1.3에서는 일반 HKDF-Expand 위에
HKDF-Expand-Label
이라는 구조를 하나 더 정의한다.
개념적으로 다음과 같다.
HKDF-Expand-Label(
Secret,
Label,
Context,
Length
)
그리고 내부에서는 HkdfLabel이라는 구조를 만든다.
struct {
uint16 length;
opaque label<7..255>;
opaque context<0..255>;
} HkdfLabel;
실제 label에는 사용자가 지정한 Label만 들어가는 것이 아니다.
항상 앞에
"tls13 "
이라는 문자열이 붙는다.
예를 들어:
Label = "key"
라면 실제 HKDF context에는
"tls13 key"
가 사용된다.
"tls13 " prefix를 붙이는 이유 역시 domain separation과 관련이 있다.
TLS 1.3에서 생성한 KDF 출력이 다른 프로토콜에서 같은 secret을 사용해 만든 출력과 충돌하는 것을 피할 수 있도록 namespace를 만드는 것이다.
6. Derive-Secret
TLS Key Schedule에서 정말 많이 등장하는 함수가 있다.
Derive-Secret
정의는 개념적으로 다음과 같다.
Derive-Secret(
Secret,
Label,
Messages
)
내부적으로는
HKDF-Expand-Label(
Secret,
Label,
Transcript-Hash(Messages),
Hash.length
)
형태로 계산된다.
여기서 매우 중요한 요소가 등장한다.
바로:
Transcript-Hash
이다.
TLS 1.3에서 key는 단순히 random secret만으로 만들어지는 것이 아니다.
그 시점까지 실제로 교환한 Handshake Message의 내용까지 Key Schedule에 결합된다.
이것을 흔히 Transcript Binding이라고 볼 수 있다.
7. Transcript Hash는 무엇인가
클라이언트와 서버가 다음과 같은 TLS Handshake를 수행한다고 해보자.
Client Server
ClientHello
------------------------>
ServerHello
<------------------------
EncryptedExtensions
Certificate
CertificateVerify
Finished
<------------------------
Finished
------------------------>
TLS는 이 메시지들을 순서대로 hash한다.
예를 들어 Handshake Traffic Secret이 생성되는 시점에는 대략
ClientHello || ServerHello
의 transcript가 사용된다.
Application Traffic Secret을 만들 때는
ClientHello
...
Server Finished
까지의 transcript가 사용된다.
여기서 ...은 단순 문자열이 아니라 그 사이에 실제로 존재했던 handshake messages를 의미한다.
Transcript Hash의 중요한 점은 TLS Record Layer header가 아니라 Handshake protocol message 자체를 대상으로 한다는 것이다.
Handshake message의 type과 length 정보도 hash 입력에 포함된다.
즉 공격자가 handshake parameter 하나를 바꾸면 transcript hash 역시 달라진다.
그 결과 이후 파생되는 secret도 달라진다.
8. Cipher Suite와 Hash 함수
TLS 1.3 Cipher Suite에는 Key Schedule에서 사용할 hash 알고리즘도 포함되어 있다.
대표적으로:
TLS_AES_128_GCM_SHA256
^^^^^^
SHA-256
TLS_AES_256_GCM_SHA384
^^^^^^
SHA-384
TLS_CHACHA20_POLY1305_SHA256
^^^^^^
SHA-256
예를 들어 SHA-256을 사용하면:
Hash.length = 32 bytes
SHA-384라면:
Hash.length = 48 bytes
이 길이는 Key Schedule 곳곳에서 사용된다.
9. 전체 Key Schedule
이제 TLS 1.3의 핵심 구조를 보자.
단순화하면 다음과 같다.
PSK
│
▼
HKDF-Extract
│
▼
Early Secret
│
Derive-Secret
│
▼
"derived"
│
│
Asymmetric Shared ──────┤
Secret ▼
HKDF-Extract
│
▼
Handshake Secret
│
Derive-Secret
│
▼
"derived"
│
│
Zero ───────┐
│ │
▼ │
HKDF-Extract ◄──┘
│
▼
Main Secret
│
┌───────────────┼────────────────┐
▼ ▼ ▼
Client App Server App Resumption
Traffic Secret Traffic Secret Secret
하지만 실제로는 이보다 더 많은 secret이 파생된다.
하나씩 따라가 보자.
10. Stage 1 — Early Secret
Key Schedule의 시작은 Early Secret이다.
Early Secret =
HKDF-Extract(
salt = 0,
IKM = PSK
)
여기서 0은 숫자 하나가 아니다.
현재 cipher suite의 hash output 길이만큼의 zero byte string이다.
SHA-256이라면:
00 00 00 ... 00
총 32바이트다.
11. PSK를 사용하지 않으면?
일반적인 최초 HTTPS 연결에서는 PSK를 사용하지 않을 수도 있다.
그렇다고 Early Secret 단계 자체를 생략하지 않는다.
PSK가 없다면 PSK 자리에 역시 Hash.length 크기의 zero value가 사용된다.
즉:
Early Secret =
HKDF-Extract(0, 0)
가 된다.
이 부분은 꽤 자주 오해된다.
PSK 없음
→ Early Secret 없음
이 아니다.
정확히는:
PSK 없음
→ zero-valued PSK 사용
→ Early Secret은 여전히 계산
이다.
Key Schedule의 구조를 일정하게 유지하기 위해서다.
12. Early Secret에서 파생되는 값들
Early Secret에서는 여러 값이 만들어질 수 있다.
대표적으로:
binder_key
client_early_traffic_secret
early_exporter_secret
이다.
PSK 기반 연결이나 0-RTT에서 중요하다.
13. Binder Key
PSK를 사용할 때 클라이언트는 자신이 해당 PSK를 실제로 알고 있다는 것을 증명해야 한다.
이를 위해 ClientHello의 pre_shared_key extension에는 binder가 포함된다.
그 binder를 계산하기 위한 secret이:
binder_key
다.
외부에서 provision된 PSK라면 label:
"ext binder"
를 사용하고,
TLS session resumption에서 얻은 PSK라면:
"res binder"
를 사용한다.
즉 두 종류의 PSK가 동일한 Key Schedule 역할로 혼용되지 않도록 label 수준에서 분리되어 있다.
14. client_early_traffic_secret
0-RTT를 사용하는 경우 클라이언트는 ServerHello를 받기 전 Application Data를 보낼 수 있다.
이때 사용되는 secret이:
client_early_traffic_secret
이다.
대략:
Derive-Secret(
Early Secret,
"c e traffic",
ClientHello
)
형태다.
여기서 중요한 사실이 하나 있다.
아직 ServerHello가 없다.
따라서 이 secret에는 새로운 서버의 ephemeral asymmetric shared secret이 반영되지 않았다.
이것이 0-RTT가 일반 1-RTT Application Data보다 약한 보안 특성을 가지는 근본적인 이유 중 하나다.
15. 왜 0-RTT는 Forward Secrecy가 약한가
일반 TLS 1.3 handshake에서는 새 연결마다 asymmetric ephemeral key exchange를 수행할 수 있다.
하지만 0-RTT 데이터는 서버의 새로운 key share를 받기도 전에 보내야 한다.
따라서 0-RTT key는 이전 연결에서 얻은 PSK에 의존한다.
결과적으로 TLS 1.3은 0-RTT 데이터에 대해 일반적인 1-RTT 데이터와 동일한 Forward Secrecy를 보장하지 않는다.
또한 0-RTT는 cross-connection replay 문제가 존재한다.
그래서 다음과 같은 상태 변경 요청을 아무 생각 없이 0-RTT로 처리하는 것은 위험할 수 있다.
송금 실행
상품 주문
포인트 사용
계정 상태 변경
0-RTT는 단순히
TLS를 더 빠르게 만드는 기능
이라고만 이해하면 안 된다.
성능을 얻는 대신 일부 보안 특성을 양보하는 구조다.
16. Early Secret → Handshake Secret으로 넘어가기
Early Secret을 그대로 HKDF-Extract의 salt로 사용하지 않는다.
먼저:
Derive-Secret(
Early Secret,
"derived",
""
)
를 계산한다.
이를 편의상:
derived_secret_early
이라고 부르자.
그리고 비로소 새로운 asymmetric shared secret을 결합한다.
Handshake Secret =
HKDF-Extract(
derived_secret_early,
AsymmetricSharedSecret
)
전통적인 TLS 1.3에서는 이 값이 흔히 (EC)DHE shared secret이었다.
최신 RFC에서는 향후 KEM 등 다른 asymmetric key establishment 방식까지 포괄하기 위해 보다 일반적인 Asymmetric Shared Secret이라는 표현을 사용한다.
17. 왜 중간에 "derived"를 넣는가
여기서 이런 의문이 생길 수 있다.
왜 그냥:
HKDF-Extract(
Early Secret,
AsymmetricSharedSecret
)
을 하지 않을까?
왜 굳이:
Early Secret
↓
Derive-Secret("derived")
↓
HKDF-Extract
라는 단계를 넣을까?
TLS 1.3 Key Schedule의 중요한 설계 원칙 때문이다.
HKDF-Extract를 연속으로 단순 chaining하지 않고, 중간에 HKDF-Expand 기반 derivation을 거치도록 구성한다.
이렇게 하면 Key Schedule의 각 단계가 더 명확하게 분리된다.
즉:
Extract
↓
Expand
↓
Extract
↓
Expand
형태가 된다.
Key Schedule의 서로 다른 stage 사이에도 암호학적인 separation을 제공하는 구조다.
18. Handshake Secret
이제 TLS에 새로운 entropy가 본격적으로 들어왔다.
Handshake Secret
은:
이전 PSK 기반 상태
+
현재 연결의 asymmetric shared secret
을 결합한 결과다.
일반적인 full handshake에서는 PSK가 없으므로 사실상 새 asymmetric key exchange가 이 단계의 핵심 entropy source가 된다.
Handshake Secret으로부터:
client_handshake_traffic_secret
server_handshake_traffic_secret
이 만들어진다.
19. Client Handshake Traffic Secret
대략적인 계산은:
client_handshake_traffic_secret =
Derive-Secret(
Handshake Secret,
"c hs traffic",
ClientHello...ServerHello
)
이다.
20. Server Handshake Traffic Secret
서버 방향은 별도의 label을 사용한다.
server_handshake_traffic_secret =
Derive-Secret(
Handshake Secret,
"s hs traffic",
ClientHello...ServerHello
)
주목할 점은 두 secret의 root가 같다는 것이다.
Handshake Secret
이라는 동일한 secret에서 출발하지만:
"c hs traffic"
"s hs traffic"
이라는 서로 다른 label을 사용한다.
따라서 결과는 독립적으로 분리된다.
21. 왜 Client와 Server의 Key를 따로 만드는가
만약 양쪽이 완전히 동일한 key와 IV를 사용한다면 cryptographic state 관리가 훨씬 어려워진다.
TLS에서는 방향별 secret을 처음부터 분리한다.
즉:
Client → Server
client_*_traffic_secret
Server → Client
server_*_traffic_secret
를 각각 사용한다.
이를 통해 송신 방향마다 독립적인:
key
IV
sequence number
상태를 유지할 수 있다.
22. Handshake Traffic Secret은 아직 실제 AES Key가 아니다
여기가 중요하다.
server_handshake_traffic_secret
을 그대로 AES-GCM key로 사용하지 않는다.
TLS에서는 여기에서 다시 실제 Record Layer key를 파생한다.
예를 들어:
write_key =
HKDF-Expand-Label(
traffic_secret,
"key",
"",
key_length
)
그리고:
write_iv =
HKDF-Expand-Label(
traffic_secret,
"iv",
"",
iv_length
)
을 계산한다.
따라서 구조는:
Handshake Secret
↓
Handshake Traffic Secret
↓
┌─────────────┐
▼ ▼
write_key write_iv
이다.
23. AES-128-GCM을 사용한다면
예를 들어 cipher suite가:
TLS_AES_128_GCM_SHA256
이라고 하자.
그러면:
Hash = SHA-256
Hash.length = 32 bytes
이지만 실제 AES key는:
16 bytes
다.
따라서 Traffic Secret은 32바이트라도:
HKDF-Expand-Label(
Secret,
"key",
"",
16
)
을 통해 16바이트 AES key가 만들어진다.
IV는 AES-GCM TLS 1.3에서 12바이트다.
즉:
HKDF-Expand-Label(
Secret,
"iv",
"",
12
)
의 결과가 write IV가 된다.
Traffic Secret의 길이와 실제 symmetric key 길이는 반드시 같지 않다.
이 차이를 알아두는 것이 중요하다.
24. TLS Record마다 IV를 새로 만드는가?
아니다.
TLS는 Traffic Secret에서 하나의 static write IV를 생성한다.
그리고 각 Record의 sequence number를 이용해 실제 nonce를 만든다.
개념적으로:
nonce =
write_iv
XOR
padded_sequence_number
이다.
각 송신 방향은 독립적인 64-bit record sequence number를 유지한다.
0
1
2
3
...
sequence number가 변하므로 같은 traffic key를 사용하는 동안에도 각 TLS Record는 서로 다른 nonce를 갖는다.
AEAD에서 nonce 재사용은 매우 위험하기 때문에 이 구조가 중요하다.
25. Finished Message와 Key Schedule
Handshake Traffic Secret은 단순히 handshake record encryption에만 쓰이지 않는다.
TLS의 핵심 인증 단계인 Finished에도 연결된다.
Finished용 key는:
finished_key =
HKDF-Expand-Label(
BaseKey,
"finished",
"",
Hash.length
)
형태로 만든다.
그리고:
verify_data =
HMAC(
finished_key,
Transcript-Hash(...)
)
를 계산한다.
여기서 BaseKey는 해당 방향의 handshake traffic secret이다.
즉 Finished는 단순히:
"Handshake 끝났음"
이라는 신호가 아니다.
현재 handshake transcript와 파생된 cryptographic state를 실제로 알고 있다는 것을 확인하는 메시지다.
Handshake 도중 메시지가 조작됐다면 양쪽의 Transcript Hash가 달라진다.
그 결과 Finished verification이 실패하게 된다.
26. Main Secret 생성
Handshake 단계의 key가 만들어졌다고 끝난 것이 아니다.
이제 실제 Application Data를 위한 상위 secret을 만들어야 한다.
먼저 Handshake Secret에서 다시:
Derive-Secret(
Handshake Secret,
"derived",
""
)
를 계산한다.
이를:
derived_secret_handshake
라고 해보자.
그 뒤:
Main Secret =
HKDF-Extract(
derived_secret_handshake,
0
)
을 계산한다.
여기서 IKM은 새로운 shared secret이 아니라 zero value다.
즉 Key Schedule의 새로운 stage로 상태를 전환하는 역할을 한다.
27. Main Secret에서 파생되는 값
Main Secret으로부터 가장 중요한 값들이 나온다.
client_application_traffic_secret_0
server_application_traffic_secret_0
exporter_secret
resumption_secret
이다.
28. Application Traffic Secret
서버의 Finished까지 포함된 transcript를 사용해:
client_application_traffic_secret_0 =
Derive-Secret(
Main Secret,
"c ap traffic",
ClientHello...server Finished
)
를 만든다.
서버 방향도 마찬가지다.
server_application_traffic_secret_0 =
Derive-Secret(
Main Secret,
"s ap traffic",
ClientHello...server Finished
)
여기서 _0가 붙는 이유가 있다.
Application Traffic Secret은 handshake 이후에도 업데이트될 수 있기 때문이다.
29. Application Traffic Key
Application Traffic Secret 역시 실제 AES/ChaCha20 key가 아니다.
다시:
application_traffic_secret_0
│
├─ HKDF-Expand-Label(..., "key", ...)
│ ↓
│ write_key
│
└─ HKDF-Expand-Label(..., "iv", ...)
↓
write_iv
를 통해 실제 Record protection key를 만든다.
최종적으로 HTTPS의 HTTP 데이터 등이 이 key로 보호된다.
전체 흐름을 보면:
Asymmetric Key Exchange
↓
Shared Secret
↓
Handshake Secret
↓
Main Secret
↓
Application Traffic Secret
↓
write_key + write_iv
↓
AEAD
↓
Encrypted TLS Record
이다.
30. KeyUpdate
TLS 1.3은 연결 중 Application Traffic Key를 바꿀 수 있다.
이때:
KeyUpdate
라는 handshake message를 사용한다.
새로운 traffic secret은:
application_traffic_secret_N+1 =
HKDF-Expand-Label(
application_traffic_secret_N,
"traffic upd",
"",
Hash.length
)
형태로 만들어진다.
예를 들어:
traffic_secret_0
↓
traffic_secret_1
↓
traffic_secret_2
↓
traffic_secret_3
처럼 세대가 진행된다.
그리고 매번 새로운 traffic secret에서:
write_key
write_iv
를 다시 만든다.
31. KeyUpdate가 주는 Forward Secrecy
구현이 새 secret과 key로 전환한 뒤 이전 값을 안전하게 삭제한다고 하자.
traffic_secret_0
X 삭제
traffic_secret_1
X 삭제
traffic_secret_2
↓
현재 사용
이 경우 현재 endpoint가 compromise되어 traffic_secret_2를 공격자가 얻더라도 일방향 KDF 특성 때문에 과거의:
traffic_secret_1
traffic_secret_0
을 역산하는 것은 어렵다.
따라서 이전 세대 traffic에 대한 forward secrecy를 강화한다.
그래서 RFC는 이전 traffic secret과 관련 key가 더 이상 필요하지 않으면 삭제하도록 권장한다.
32. 하지만 Post-Compromise Security는 아니다
여기에는 중요한 한계가 있다.
KeyUpdate가:
secret_N
↓
secret_N+1
형태이기 때문이다.
만약 공격자가 현재:
secret_N
을 획득했다면 공격자는 forward 방향의 derivation 역시 수행할 수 있다.
즉:
secret_N
↓
HKDF-Expand-Label("traffic upd")
↓
secret_N+1
을 계산할 수 있다.
따라서 TLS 1.3의 단순 KeyUpdate만으로는 compromise 이후 보안을 자동으로 복구하는 Post-Compromise Security를 제공하지 않는다.
과거 방향을 보호하는 것과 compromise 이후 미래 세션을 다시 안전하게 만드는 것은 서로 다른 문제다.
33. Resumption Secret
Main Secret에서 또 하나 중요한 값이 파생된다.
resumption_secret
이다.
이 값은 client Finished까지 포함된 transcript를 사용한다.
개념적으로:
resumption_secret =
Derive-Secret(
Main Secret,
"res master",
ClientHello...client Finished
)
이다.
주의할 점:
최신 RFC에서는 값의 이름이:
resumption_secret
이지만 label은 호환성 때문에 여전히:
"res master"
이다.
34. Session Resumption
서버는 handshake 이후 NewSessionTicket을 보낼 수 있다.
이를 통해 다음 연결에서 사용할 PSK를 만들 수 있다.
결과적으로 다음 연결에서는 이전 연결에서 파생된 secret이 다시 Key Schedule의:
PSK
위치로 들어갈 수 있다.
즉 TLS 1.3의 session resumption은 별도의 완전히 독립된 메커니즘이라기보다 Key Schedule의 PSK 구조에 자연스럽게 연결된다.
대략:
Connection #1
Main Secret
↓
resumption_secret
↓
Resumption PSK
↓
Connection #2
PSK
↓
Early Secret
↓
...
이라는 구조다.
35. PSK + Asymmetric Key Exchange
TLS 1.3에서 PSK를 사용한다고 해서 반드시 asymmetric key exchange를 생략하는 것은 아니다.
두 가지를 함께 사용할 수 있다.
PSK
│
▼
Early Secret
│
▼
Asymmetric Shared Secret
│
▼
Handshake Secret
이 방식에서는 이전 연결과 cryptographic continuity를 가지면서 새로운 ephemeral asymmetric secret을 추가할 수 있다.
따라서 PSK만 사용하는 방식보다 강한 forward secrecy 특성을 제공할 수 있다.
반대로 PSK-only key establishment에서는 새로운 asymmetric shared secret이 없기 때문에 forward secrecy 측면에서 차이가 발생한다.
36. 왜 Secret을 이렇게 많이 만들까?
처음 보면 TLS 1.3 Key Schedule은 지나치게 복잡해 보인다.
왜 그냥:
shared_secret
↓ SHA-256
AES key
를 사용하지 않을까?
대표적인 이유는 세 가지다.
1. Key Separation
다음 역할이 모두 분리된다.
Client Handshake Encryption
Server Handshake Encryption
Client Application Encryption
Server Application Encryption
Finished Authentication
Exporter
Session Resumption
0-RTT
하나의 key를 여러 목적으로 재사용하지 않는다.
2. Transcript Binding
key가 현재 handshake transcript에 묶인다.
즉 단순히:
같은 shared secret
이라는 사실만으로 같은 Traffic Secret이 만들어지는 것이 아니다.
Handshake에 포함된:
ClientHello
ServerHello
각종 extension
random
협상된 parameter
...
의 transcript가 달라지면 파생 secret 역시 달라진다.
RFC는 Hello 메시지의 random 값들이 transcript에 포함되기 때문에 동일한 PSK를 여러 연결에서 사용하더라도 각 handshake의 traffic secret은 달라진다는 점을 명시하고 있다.
3. Stage Separation
TLS의 보안 상태를 단계별로 나눈다.
Early
↓
Handshake
↓
Application
각 단계에서 서로 다른 secret을 사용하기 때문에 이전 단계의 key를 다음 단계에서 계속 재사용하지 않는다.
37. TLS 1.3 Key Schedule의 핵심을 한 장으로 정리
최종적으로 전체 구조를 조금 더 자세히 표현하면 다음과 같다.
0
│
▼
PSK ───────────▶ HKDF-Extract
│
▼
Early Secret
│
┌───────────┼──────────────┐
│ │ │
▼ ▼ ▼
binder_key client early early exporter
traffic secret secret
│
▼
Derive-Secret
"derived"
│
│
Asymmetric Shared ────┤
Secret ▼
HKDF-Extract
│
▼
Handshake Secret
│
┌───────────┴───────────┐
│ │
▼ ▼
client handshake server handshake
traffic secret traffic secret
│ │
key / IV key / IV
│ │
└───────────┬───────────┘
│
▼
Derive-Secret
"derived"
│
▼
HKDF-Extract
IKM = zero
│
▼
Main Secret
│
┌───────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
client application server application exporter
traffic secret traffic secret secret
│ │
key / IV key / IV
│ │
│ │
└───── Application Data ─────┘
Main Secret
│
▼
resumption_secret
│
▼
Resumption PSK
│
▼
Future Handshake
38. 가장 중요한 포인트: Key Schedule은 하나의 트리다
TLS 1.3 Key Schedule을 단순한 일렬 과정으로만 보면 이해가 어렵다.
실제로는 하나의 cryptographic key tree라고 보는 편이 낫다.
Root Secret
│
┌──────────────┼──────────────┐
│ │ │
client server exporter
│ │
key / IV key / IV
그리고 tree의 각 branch마다 서로 다른:
Label
Context
Transcript Hash
가 사용된다.
이 때문에 하나의 상위 secret에서 여러 값을 안전하게 분리하여 사용할 수 있다.
39. 실제 TLS 구현에서 Secret 관리가 중요한 이유
Key Schedule 자체가 안전하더라도 구현체가 모든 secret을 메모리에 계속 보관하면 보안성이 떨어진다.
예를 들어 handshake가 끝났는데도:
Early Secret
Handshake Secret
handshake traffic key
old application traffic key
등을 모두 메모리에 남겨두면 memory disclosure 취약점이 발생했을 때 과거 통신 보호 범위가 커질 수 있다.
TLS 표준이 더 이상 필요하지 않은 secret을 삭제하도록 권장하는 이유다.
암호 프로토콜에서는:
어떻게 key를 만드는가
만큼이나
언제 key를 지우는가
도 중요하다.
40. Key Schedule을 pseudocode로 표현하면
전체 구조를 구현 관점에서 단순화하면 다음과 같다.
zero = 0x00 * HashLen
# Stage 1: Early
early_secret =
HKDF-Extract(zero, PSK_or_zero)
derived_early =
Derive-Secret(
early_secret,
"derived",
""
)
# Stage 2: Handshake
handshake_secret =
HKDF-Extract(
derived_early,
asymmetric_shared_secret
)
client_hs_secret =
Derive-Secret(
handshake_secret,
"c hs traffic",
transcript(ClientHello...ServerHello)
)
server_hs_secret =
Derive-Secret(
handshake_secret,
"s hs traffic",
transcript(ClientHello...ServerHello)
)
# Stage 3: Main
derived_handshake =
Derive-Secret(
handshake_secret,
"derived",
""
)
main_secret =
HKDF-Extract(
derived_handshake,
zero
)
# Stage 4: Application
client_app_secret_0 =
Derive-Secret(
main_secret,
"c ap traffic",
transcript(ClientHello...server Finished)
)
server_app_secret_0 =
Derive-Secret(
main_secret,
"s ap traffic",
transcript(ClientHello...server Finished)
)
# Stage 5: Record key
client_write_key =
HKDF-Expand-Label(
client_app_secret_0,
"key",
"",
key_length
)
client_write_iv =
HKDF-Expand-Label(
client_app_secret_0,
"iv",
"",
iv_length
)
실제 구현에서는 transcript state, PSK binder, Finished, exporter, resumption 등 추가 상태 관리가 필요하지만 핵심 Key Schedule은 이 흐름으로 이해할 수 있다.
41. TLS 1.3 Key Schedule이 해결하려는 문제
TLS 1.3의 Key Schedule은 단순히 "강한 암호화 키 하나를 만드는 알고리즘"이 아니다.
오히려 다음 문제들을 동시에 해결하기 위한 구조에 가깝다.
하나의 entropy source를 여러 목적으로 안전하게 나눌 것
client와 server 방향의 key를 분리할 것
handshake와 application key를 분리할 것
handshake transcript에 key를 묶을 것
PSK와 새로운 asymmetric secret을 결합할 것
session resumption을 기존 구조 안에 통합할 것
traffic key 교체를 지원할 것
과거 key를 삭제하여 compromise 범위를 줄일 것
그래서 TLS 1.3에서 중요한 것은 개별 암호 알고리즘만이 아니다.
AES
SHA-256
X25519
HKDF
각각이 아무리 안전해도 이들을 잘못 조합하면 안전한 프로토콜이 만들어지지 않는다.
TLS 1.3 Key Schedule은 이 구성 요소들을 어떤 순서와 context로 결합하는가를 정의한다.
42. 결론
TLS 1.3에서는 Diffie-Hellman으로 얻은 shared secret을 바로 암호화 key로 사용하지 않는다.
대신 HKDF를 기반으로 계층적인 Key Schedule을 구성한다.
핵심 흐름은:
PSK
↓
Early Secret
↓
Asymmetric Shared Secret 결합
↓
Handshake Secret
↓
Handshake Traffic Secrets
↓
Main Secret
↓
Application Traffic Secrets
↓
write_key / write_iv
↓
AEAD
↓
TLS Record
이다.
그리고 각 단계에서:
HKDF-Extract
HKDF-Expand-Label
Derive-Secret
Transcript-Hash
가 서로 다른 역할을 수행한다.
특히 TLS 1.3 Key Schedule의 핵심 설계 원칙은 다음 네 가지로 압축할 수 있다.
1. Key Separation
2. Domain Separation
3. Transcript Binding
4. Forward Secrecy를 고려한 Secret Lifecycle
결국 TLS 1.3이 강력한 이유는 단순히 AES-256이나 SHA-384 같은 강한 알고리즘을 사용해서만은 아니다.
어떤 secret에서 어떤 key를 파생하고, 그 key를 어떤 handshake context에 묶고, 언제 폐기할 것인지까지 프로토콜 차원에서 엄격하게 설계되어 있기 때문이다.
참고 규격
RFC 9846 — The Transport Layer Security (TLS) Protocol Version 1.3
- §4.7.3 Key and Initialization Vector Update
- §5.3 Per-Record Nonce
- §7.1 Key Schedule
- §7.2 Updating Traffic Secrets
- §7.3 Traffic Key Calculation
- §7.4 Asymmetric Shared Secret Calculation
- §8 0-RTT and Anti-Replay
- Appendix F Security Properties
RFC 5869 — HMAC-based Extract-and-Expand Key Derivation Function (HKDF)
RFC 8446 — 이전 TLS 1.3 규격. 현재 RFC 9846에 의해 대체됨.
