시간은 그냥 '참고값'이 아니에요
개발하다 보면 NTP(Network Time Protocol)를 그냥 "시계 맞추는 프로토콜" 정도로 생각하기 쉬운데요. 실제로는 인증서 검증, 토큰 만료, 재전송 공격 방어, 로그 순서 보장 등 거의 모든 보안 결정의 기준값이 바로 이 시간이에요.
문제는 NTP가 1985년에 설계된 이후로 인증 기능이 전혀 없다는 점이에요. 클라이언트가 48바이트를 보내면, 누군가 48바이트를 돌려주고, 클라이언트는 그걸 그대로 믿어요. 서명도 없고 신원 확인도 없죠.
X.509 인증서의
notBefore/notAfter를 검증하려면 현재 시간이 필요하고, 현재 시간을 신뢰하려면 인증서 검증이 필요하다? 이게 바로 순환 구조예요.
과거에는 시계가 몇 초 틀려도 운영상 불편한 정도였는데, 지금은 다릅니다. 특히 CA/Browser Forum의 SC-081v3로 TLS 인증서 유효기간이 2026년 3월 200일 → 2027년 3월 100일 → 2029년 3월 47일로 단축되면, 시계가 틀어진 호스트의 자동 갱신 루프는 아무도 안 보는 새벽에 조용히 실패해요.
Meta는 이 문제를 풀기 위해 nts.meta.com을 공개하고, 서버·클라이언트·프로토콜 구현을 전부 GitHub에 오픈소스로 풀었어요. 근거자료는 Meta 엔지니어링 블로그에서 확인할 수 있어요.
![]()
NTS는 2단계로 동작해요
Phase 1: NTS-KE (키 교환)
TCP/4460 위에서 TLS 1.3으로 핸드셰이크하고, ALPN은 ntske/1을 써요. 여기서 AEAD 알고리즘을 협상하고, RFC 5705 exporter로 세션 키 2개(C2S, S2C)를 뽑아내요. 서버는 쿠키 8개를 발급하고, 어떤 NTP 서버로 붙어야 하는지 알려주고 연결을 닫아요. 패킷마다가 아니라 딱 한 번이에요.
Phase 2: 인증된 NTP
UDP/123으로 NTPv4 패킷을 보내는데, 여기에 NTS 확장 필드가 붙어요. 요청에는 고유 식별자, 쿠키, 인증자가 들어가고요. 서버는 쿠키를 열어 세션 키를 복구하고 인증자를 검증한 뒤 응답해요. 위조 패킷은 검증 실패로 조용히 드롭되고, NTS NAK도 보내지 않아서 공격자가 이 실패를 악용해 클라이언트를 강제로 재키교환 시킬 수 없어요.
상태 없는 쿠키 설계가 핵심
보통 쿠키라고 하면 서버가 상태를 들고 있어야 한다고 생각하는데, Meta는 키 링을 복제하지 않아요. 대신 마스터 시크릿과 현재 epoch day로 봉인 키를 파생시켜요.
# 쿠키 봉인 키 파생 (의사 코드)
# master: 서버 공유 마스터 시크릿
# day: Unix epoch 기준 24시간 단위 정수 (타임존/DST 없음)
import hmac, hashlib, struct
def derive_cookie_seal_key(master: bytes, day: int) -> bytes:
# HKDF-SHA256(master, salt=BE32(day), info="fbnts-cookie-seal-v1")
salt = struct.pack(">I", day)
prk = hmac.new(salt, master, hashlib.sha256).digest()
return hmac.new(prk, b"fbnts-cookie-seal-v1", hashlib.sha256).digest()
# 서버는 이 키로 쿠키를 AES-SIV로 열고 닫음
# 키 링 복제 없음, 세션 테이블 없음, 공유 상태 없음
이 설계 덕분에 KE 서버와 NTP 응답 서버가 서로 다른 머신이어도 아무것도 주고받을 필요가 없어요. 서버 추가는 그냥 서버 추가예요.
chrony 설정은 딱 한 줄
# /etc/chrony/chrony.conf
pool nts.meta.com nts iburst maxsources 5
nts.meta.com은 키 교환만 담당하고, 핸드셰이크 중에 실제 NTP 응답자를 알려줘요. 그래서 설정한 호스트와 실제로 통신하는 호스트가 달라요. server 대신 pool을 쓰는 이유는 단일 장애점을 피하고 NTP가 여러 소스로 다수결을 할 수 있게 하기 위해서예요.
# chrony sources 확인
chronyc -N authdata
# Name/IP address Mode KeyID Type KLen Last Atmp NAK Cook CLen
# time1.meta.com NTS 5 30 128 27 0 0 8 68
# time2.meta.com NTS 7 30 128 142 0 0 8 68
# time3.meta.com NTS 10 30 128 548 0 0 8 68
# time4.meta.com NTS 1 30 128 1177 0 0 8 68
# time5.meta.com NTS 2 30 128 1177 0 0 8 68
Type 30은 협상된 AEAD(AES-128-GCM-SIV), KLen 128은 세션 키 길이, CLen 68은 쿠키 크기(36바이트 봉투 + 16바이트 키 2개), Cook 8은 보유 쿠키 수, NAK 0은 거부된 적 없음을 의미해요.

NTS가 막아주는 것과 못 막는 것
| 항목 | NTS 지원 여부 | 설명 |
|---|---|---|
| MITM 시계 조작 | ✅ 방어 | 서명 검증 실패 패킷은 드롭. 시계 되돌리기/앞당기기 공격 차단 |
| 재전송 공격 | ✅ 방어 | 고유 식별자 불일치로 매칭 실패 |
| 클라이언트 추적 | ✅ 방어 | 쿠키가 응답 인증자 안에 암호화되어 매 교환마다 다른 blob |
| 지연 공격(Delay) | ❌ 불가 | RFC 8915 명시. 패킷 보류 시 최대 지연의 절반까지 시계 이동 가능 |
| 부트스트래핑 | ❌ 불가 | NTS-KE가 TLS 위에서 돌아서 결국 시계가 필요 (순환 문제) |
| 잘못된 서버 | ❌ 불가 | 경로는 인증하지만 답의 정확성은 보증 안 함. 쿼럼/모니터링 필수 |
| 단조성(Monotonic) | ❌ 불가 | wall clock은 윤초나 보정으로 뒤로 갈 수 있음. 경과 시간 측정엔 monotonic clock 사용 |
진짜 큰 문제는 모바일이에요
서버 진영은 chrony, ntpsec이 이미 NTS를 구현했고, 공개 NTS 서버 목록도 PTB, Netnod, time.nl 등 꽤 있지만 Android와 iOS의 기본 시간 클라이언트는 여전히 무인증 SNTP예요.
- Android: 플랫폼 시간 동기화는 UDP/123 위의 plain SNTP (고정 48바이트, 확장 필드 처리 없음) + NITZ + GNSS. NTS 경로 없음.
- Apple:
timed가time.apple.com과 동기화하는데 NTS 지원은 확인되지 않음.
이 두 스택이 수십억 대의 폰·워치·헤드셋 시계를 세팅하는데, 전부 무인증 UDP 패킷을 신뢰하고 있는 거예요. 벤더가 chrony를 따로 올려서 우회할 수는 있지만, 플랫폼이 직접 해야 할 일을 우회하는 것뿐이죠.
2036년 NTP 32비트 래핑 이후로 시계가 밀린 기기는 TLS 실패 → 업데이트 서버 접근 불가 → 패치 배포 불가의 데드락에 빠져요. 공장 초기화나 서비스 센터행밖에 답이 없죠. 함대 단위로 생각하면 그냥 물류 문제가 돼요.
![]()
실무 적용을 위한 정리
- 서버 운영자라면: 공개 시간 서비스를 돌리고 있다면 NTS를 켜세요. Meta 구현체가 GitHub에 있어요.
- NTP 클라이언트 관리자라면: RFC 8915를 구현하거나 chrony/ntpsec으로 갈아타세요.
- 모바일 OS 시간 스택 담당자라면: 여기가 진짜 갭이에요. Android/iOS 둘 다 아직 무인증 패킷이에요.
- 일반 백엔드 개발자라면: 경과 시간 측정에는 반드시 monotonic clock을 쓰고, 시계 보정이 필요한 로직은 쿼럼과 sanity limit을 두세요.
NTS는 만능이 아니에요. 경로는 인증하지만 답의 정확성은 보증하지 않고, 지연 공격은 못 막고, 부트스트래핑 순환 문제도 남아 있어요. 그래도 "아무도 아닌 누군가가 준 시계"에서 "검증된 발신자가 준 시계"로 넘어가는 것만으로도 인증서 갱신 자동화 시대에는 충분히 의미 있는 진전이에요.