TLS 1.3은 보기 드물게 더 빠르면서 동시에 더 안전해진 표준이다. 핸드셰이크를 2 round-trip에서 1로 줄였고, 깨진 cipher를 전부 버렸고, forward secrecy를 필수로 만들었다. 거의 모든 변경이 명백한 승리였다.
그런데 0-RTT가 있다.
0-RTT — “zero round-trip time”, early data라고도 한다 — 는 돌아온 클라이언트가 핸드셰이크가 끝나기도 전에, 첫 패킷에 바로 애플리케이션 데이터를 실어 보내게 해준다. 서버가 인사를 되돌려줄 때까지 안 기다린다. 재개(resumption)된 연결에선 round-trip 하나를 통째로 아끼는데, 지연 큰 모바일 회선에선 이게 ‘즉각 뜨는 페이지’와 ‘굼뜬 페이지’의 차이다. TLS 1.3에서 사람들이 속도 때문에 일부러 켜는 유일한 기능이다.
그리고 TLS 1.3에서 프로토콜의 나머지가 그렇게 지키려 애쓴 보안 속성을 되돌려준 유일한 기능이기도 하다. 이걸 켠 사람 대부분은 자기가 뭘 맞바꿨는지 모른다.
당신이 실제로 켠 것
메커니즘부터 보자. 메커니즘이 문제의 전부니까.
보통의 TLS 데이터는 핸드셰이크 중에 벌어지는 새 Diffie-Hellman 교환에서 키를 얻는다. 양쪽이 무작위 임시(ephemeral) 키 재료를 내놓기 때문에, 세션 키는 핸드셰이크가 돌기 전까지 어디에도 존재하지 않고 끝나면 버려진다. 그게 forward secrecy다: 내일 서버의 장기 개인키를 훔쳐도 오늘 녹음해 둔 트래픽은 복호화할 수 없다. 필요한 키가 애초에 디스크에 닿은 적이 없으니까.
0-RTT는 그 교환을 기다릴 수 없다 — round-trip을 건너뛰는 게 핵심이니까. 그래서 early data는 이전 세션의 pre-shared key(PSK)에서 파생한 키로 암호화된다. 그 PSK는 resumption ticket으로 클라이언트에게 건네졌던 것이다. 클라이언트는 이미 갖고 있던 키로 첫 요청을 암호화해서 즉시 쏜다.
여기서 두 가지가 따라 나오고, RFC 8446은 둘 다 Section 2.3에서 대놓고 말한다.
첫째, early data는 forward secret하지 않다. 새 ephemeral 교환이 아니라 넘겨받은 PSK에서 파생한 키로 보호된다. 서버는 resumption ticket을 session-ticket 암호화 키로 복호화하는데, 대부분의 배포에서 이 키는 수명이 길고 서버 무리 전체가 공유한다. 이 키가 한 번이라도 새면, 누군가 녹음해 둔 0-RTT 데이터가 전부 읽힌다 — 그리고 0-RTT 데이터는 매 연결의 첫 요청, 즉 세션 쿠키와 인증 토큰이 들어앉는 바로 그 자리다. 프로토콜은 나머지 트래픽에 forward secrecy를 주려고 엄청난 공을 들여놓고, 정작 가장 민감한 바이트만 줄을 서지 않고 지나가게 둔다.
둘째, 그리고 더 나쁘게: early data는 설계상 리플레이가 된다. 스펙이 이 점을 분명히 한다. 서버는 리플레이된 0-RTT 요청과 원본을 암호학적으로 구분할 방법이 없다. 당신의 early-data 패킷을 가로챈 공격자는 그걸 서버로 다시 보낼 수 있다 — 여러 번, 어쩌면 같은 클러스터의 다른 노드로. 그러면 서버는 그걸 복호화한다. 서버가 받아들이는 키로 정당하게 암호화돼 있으니까. 한 번 쓰고 타버리는 nonce 같은 게 프로토콜에 없다. TLS의 다른 모든 부분이 공짜로 주는 리플레이 저항이, 첫 flight에는 그냥 없다.
“그냥 GET만 허용하면 되잖아”
리플레이 문제의 표준 답은 ‘리플레이해도 안전한 요청’에만 0-RTT를 허용하는 것이다. 예컨대 Cloudflare는 2017년 0-RTT를 도입하면서 query string 없는 GET 요청에만 허용했고, origin이 알아채도록 헤더를 붙였다. 논리는 이렇다: GET은 safe해야 한다 — 읽기만 하고 아무것도 바꾸지 않는다 — 그러니 리플레이해도 무해하다.
그 논리는 현실 코드가 어마어마하게 많이 깨는 약속 위에 서 있다.
GET /logout. GET /api/charge?amount=10. GET /admin/delete?id=42. 전부 존재하면 안 되는데, 전부 지금 이 순간 프로덕션에 존재한다. GET은 side effect가 있으면 안 된다는 HTTP 스펙의 그 부분을 안 읽은 사람들이 짠 코드다. “safe method”는 표준이 지켜달라고 부탁하는 계약이지 서버가 강제하는 법이 아니다. 당신 앱의 GET 하나가 상태를 바꾸는 순간, 0-RTT를 GET으로 제한하는 건 완화책이 아니라 그 엔드포인트에 겨눈 장전된 총이 된다 — 이제 경로상의 누구나 공짜로 리플레이할 수 있다.
도움이 된다던 query-string 제한은? side effect를 URL path로, 또는 다른 요청이 바꾼 상태를 읽는 엔드포인트로 밀어 넣을 뿐이다. “이론상 idempotent, 현실에선 위험”은 0-RTT 보안 분석마다 반복되는 주제이고, 그래서 정직한 조언은 이것이다: 어떤 요청이 side effect가 0이라고 증명할 수 없으면, 그 요청은 절대 0-RTT에 태우면 안 된다.
세 가지 방어, 전부 완전하진 않은
RFC 8446은 Section 8 전체를 anti-replay에 쓴다. 그리고 이 섹션은 ‘고쳤다’고 약속하기를 얼마나 조심스럽게 거부하는지 때문에 읽을 가치가 있다. 세 가지 메커니즘을 제시한다.
Single-use tickets(§8.1): 서버가 각 resumption ticket을 기억하고 정확히 한 번만 받아준다. 이론상 깔끔하다. 문제는 애초에 0-RTT를 원하는 이유가 보통 edge 노드 수백 개짜리 CDN이라는 것이고, “이 ticket은 이미 썼다”는 그 전부에 걸친 공유 상태다. 그걸 edge 지연 수준으로, 전 세계적으로 동기화하는 건 엔지니어링 분기를 통째로 잡아먹는 종류의 문제다. 그래서 엄격한 single-use를 하지 않는 대형 배포가 많다.
ClientHello recording(§8.2): 서버가 각 ClientHello의 고유값을 기록하고 일정 창 안의 중복을 거부한다. 같은 분산 상태 문제, 같은 이유로 부분적이다.
Freshness checks(§8.3): 서버가 ticket이 주장하는 나이를 발급 시점과 대조해 좁은 창을 벗어난 걸 거부한다. 공격자가 리플레이할 수 있는 시간을 묶지만, 리플레이 자체를 막진 못한다 — 창을 몇 초로 줄일 뿐이다. 즉시 리플레이하는 공격자는 여전히 이긴다.
셋을 함께 읽으면 스펙 자신의 입장이 분명하다. 모든 서버가 상태를 공유한다면 single-use ticket이나 ClientHello 기록으로 early data를 최대 한 번만 받도록 보장할 수 있다 — 하지만 RFC는 “모든 배포가 그 수준의 상태를 유지하지는 않을 것”이라고 인정하고, 더 약한 바닥선(서버 인스턴스 하나가 같은 0-RTT 핸드셰이크를 최대 한 번만 받음 — 리플레이 횟수의 상한이 인스턴스 수가 된다)을 정하며, 클라이언트에게는 리플레이돼도 안전하다고 판단한 데이터만 early data로 보내라고 한다. 상태가 완벽해도 8절이 설명하는 구역 간 수법 — 0-RTT를 거절한 서버가 클라이언트를 전체 핸드셰이크로 밀어 같은 요청을 재시도하게 만드는 것 — 은 막지 못한다. 그게 조용한 자백이다. 0-RTT 리플레이는 패치할 수 있는 버그가 아니라 관리해야 하는 속성이다.
이걸 제대로 짚은 층
이 문제를 가장 진지하게 다룬 쪽은 HTTP 워킹그룹인데, early data를 HTTP에서 데지 않고 쓰는 법만 다룬 RFC를 통째로 하나 — 8470 — 썼다. 핵심 수는 425 Too Early 상태 코드다: 어떤 요청이 리플레이-안전한지 확신 못 하는 origin은 그 요청을 거부하고, 정상적으로 완전히 핸드셰이크된 연결에서 같은 요청을 다시 보내라고 클라이언트에게 알린다. “리플레이당하느니 느린 게 낫다”고 명시적으로 말하는 탈출구다.
그게 옳은 본능이고, 나는 사람들이 이 기능 전체에 이 프레임을 더 썼으면 한다. 0-RTT는 load balancer 설정에서 켜는 공짜 속도 향상이 아니다. round-trip 하나와 맞바꿔 리플레이 가능하고 forward secret하지 않은 early data를 받아들이겠다는 결정이다 — 민감한 것도, 상태를 바꾸는 것도 클라이언트의 첫 패킷에는 절대 타지 않는다는 데 거는 베팅이다.
정적 이미지 CDN이라면 그 베팅은 괜찮다 — 속도를 가져가라. 뭐라도 하는 API거나, 첫 요청에 세션이 실리는 앱이라면, 언젠가는 지는 베팅이고, 지는 모습은 두 번 결제된 청구서나 되돌릴 수 없이 새어버린 토큰으로 나타난다.
거의 모두에게 정직한 기본값은 0-RTT를 꺼두는 것이다. 이 기능은 고장 난 게 아니다. 말한 그대로 정확히 한다. 문제는 그 ‘말한 것’이 “당신의 리플레이 보호를 50밀리초와 맞바꿨다”는 것이고, 아무도 영수증을 안 읽는다는 데 있다.