프로덕션까지 흘러가는 TLS 실수 가운데 가장 흔한 것 하나가 있다. 그리고 버그처럼 보이지 않기 때문에 바로 거기까지 간다. 사이트는 작동한다. 크롬으로 열면 자물쇠가 떠 있고, 인증서는 유효하고, 모두 넘어간다. 그러다 백엔드 서비스가 같은 호스트를 호출하려다 unable to get local issuer certificate로 죽는다. 혹은 모바일 앱이 거부한다. 혹은 가동 감시가 알림을 쏘기 시작한다. 서버에선 바뀐 게 없는데 — 어떻게 브라우저에선 되고 그 외 어디서나 실패할 수 있을까?
당신의 인증서 체인이 불완전하고, 크롬이 당신을 대신 덮어 주고 있기 때문이다.
서버가 보내야 하는 것
TLS 인증서는 홀로 서지 않는다. 체인의 맨 아래다: 당신의 리프(leaf) 인증서(당신 도메인용)는 중간(intermediate) CA가 서명하고, 그건 다시 — 직접 혹은 더 많은 중간을 거쳐 — 루트(root) CA가 서명한다. 클라이언트가 루트를 신뢰하는 건 루트가 신뢰 저장소에 박혀 있어서다. 하지만 당신의 중간은 갖고 있지 않다; 중간은 생겼다 사라지고, 수천 개가 있다. 그래서 클라이언트는 당신의 리프에서 신뢰하는 루트까지 경로를 세워야 하고, 그러려면 루트를 뺀 모든 고리가 필요하다.
그게 서버의 몫이다. 핸드셰이크 동안 서버는 리프 와 중간(들)을 — 당신 인증서와 루트 사이의 모든 것을 — 보내야 한다. 루트는 건너뛰어도 되는데, 클라이언트가 이미 갖고 있기 때문이다.
이걸 틀리는 가장 흔한 방법은 서버에 리프만 설정하는 것이다. nginx에선 ssl_certificate를 fullchain.pem(리프 + 중간, 올바름)으로 가리키느냐 맨 cert.pem(리프만, 깨짐)으로 가리키느냐의 차이다. 수많은 인증서 도구와 관리 패널이 각자의 방언으로 같은 실수를 한다. 결과는, 리프만 건네고 멈추는 서버 — 클라이언트에게 “받은 적 없고 검증할 수도 없는 기관이 서명한 인증서”를 쥐여 주는 것이다.
그리고 요점은 이거다: 그 서버는 틀리게 설정됐다, 두말할 것 없이. TLS 명세가 반드시 보내야 한다고 말하는 걸 보내지 않고 있다. 다만 당신이 들여다본 그 한 곳에서 우연히 작동할 뿐이다.
크롬은 왜 신경 쓰지 않나
당신의 리프 인증서 안에 Authority Information Access — AIA, RFC 5280에 정의 — 라는 확장이 묻혀 있다. 그 필드 중 하나인 caIssuers는 발급 CA가 당신 걸 서명한 인증서를 게시해 둔 URL이다. 힌트로 쓰라고 있는 것이다.
크롬은 그걸 지시로 취급한다. 중간을 보내는 걸 잊은 서버에 닿으면, 크롬은 리프에서 AIA URL을 읽어 빠진 중간을 평문 HTTP로 받아 와 체인에 끼우고 검증을 완료한다 — 당신에게 아무것도 보여주기 전에 전부. 당신은 자물쇠를 얻는다. 경고는 없다. 서버가 제 몫을 못 했다는 어떤 표시도 없다. 크롬이 그 일을 조용히 대신 해 줬기 때문이다. 이걸 AIA fetching이라 부르고, 데스크톱 크롬 사용자는 사실상 불완전 체인 설정 오류에 면역이라는 뜻이다.
도움이 되는 것처럼 들리고, 바로 그게 함정이다. 거의 모두가 테스트하는 그 한 클라이언트가, 당신 체인이 올바른지에 대해 당신에게 거짓말을 할 바로 그 한 클라이언트다.
나머지 모두는 당신이 실제로 보낸 걸 검증한다
브라우저 밖으로 한 발 나가면 안전망이 사라진다:
- curl / OpenSSL:
unable to get local issuer certificate. - Java:
PKIX path building failed … unable to find valid certification path to requested target. - Go:
x509: certificate signed by unknown authority. - Python, Node, Ruby, 안드로이드 앱, 그리고 대부분의 머신 대 머신 클라이언트: 같은 하드 실패의 어떤 변형.
이들 중 누구도 빠진 중간을 받아 오지 않는다. 서버가 보낸 것 그대로에서 경로를 세우고, 중간이 그 묶음에 없으면 경로가 신뢰 루트에 닿지 못해 검증이 실패한다 — 올바르게. 깐깐한 게 아니다. 브라우저가 하는 척만 하는 일, 즉 서버가 완전하고 검증 가능한 체인을 제시했는지 확인하는 일을 하는 것이다. (예외는 검증을 윈도우나 애플의 시스템 라이브러리에 맡기는 클라이언트다 — 이 플랫폼들도 빠진 중간을 받아 오기 때문에, “내 PC에선 된다”가 한 번 더 굳어진다.)
파이어폭스는 다른 길로 같은 결과에 닿았다. 모질라는 주로 개인정보 때문에 AIA fetching을 구현하지 않았다 — 받아 올 때마다 방금 어느 사이트를 방문했는지 CA에 알려 주게 되니까. 대신 파이어폭스 75(2020년)부터 데스크톱 파이어폭스는 모질라 루트 프로그램에 공개된 중간 인증서 전부를 미리 내려받는다 — 도입 당시 2천 개가 넘었다. 그래서 데스크톱 파이어폭스도 공개 CA의 불완전 체인 대부분에서 작동한다. 방식은 달라도 당신에게 미치는 효과는 같다: 테스트에 쓰는 브라우저가 빈칸을 메우고, 빈칸이 있었다는 사실은 아무도 알려 주지 않는다.
왜 이렇게 좋은 함정인가
순서대로 돌려 보자. 개발자가 TLS를 설정하고, 크롬으로 사이트를 열고, 자물쇠를 보고, 배포한다. 알아챌 오류가 없다. 그에겐 오류가 없으니까. 설정 오류는 개발·리뷰·배포를 통째로 들키지 않고 지나간다.
그러다 사람도 없고 AIA fetching도 없는 어딘가에서 터진다: 서버 대 서버 API 호출, 파트너 백엔드가 당신에게 보내는 웹훅, 결제사 콜백, 쿠버네티스 liveness 프로브, 안드로이드 앱, STARTTLS를 검증하는 메일 서버. 바로 TLS가 실제로 값진 무언가를 지키는 연결들이고, 바로 브라우저의 조용한 구조를 받지 못하는 연결들이다. 실패는 원인에서 멀리 떨어진 곳에, 흔히 남의 인프라 위에 착지하고, 진짜 문제가 당신 쪽의 빠진 중간이라는 걸 전혀 말해 주지 않는 오류 메시지 — PKIX path building failed — 로 묘사된다.
더 나아가겠다: AIA fetching은 실수였다. 인증서를 받아 오는 게 위험해서가 아니라, 지배적 클라이언트를 특정 설정 오류에 관대하게 만든 것이 그 오류가 풍토병이 되도록 보장했기 때문이다. 크롬은 깨진 체인의 비용을 인터넷의 다른 모든 TLS 클라이언트에게, 그리고 “내 브라우저에선 된다”를 “된다”로 믿는 모든 개발자에게 떠넘겼다. 크롬 주소창의 경고 하나가, RFC를 인용하는 블로그 글 10년치보다 한 달 만에 더 많은 불완전 체인을 고쳤을 것이다.
어떻게 실제로 아는가
깨진 체인의 해결책은 지루하다: 전체 체인을 보내라. 서버를 중간이 포함된 묶음으로 가리키고, 리로드하고, 끝. 최신 ACME 클라이언트가 정확히 이 이유로 fullchain.pem을 쥐여 주고, 그걸 쓰면서 올바른 파일을 가리킨다면 이 버그를 영영 안 만날 공산이 크다.
버그가 있는지 아는 건 규율이 필요한 부분이다. 당신의 본능 — 브라우저로 열어 본다 — 이 바로 그걸 탐지할 수 없는 유일한 테스트이기 때문이다. 받아 오지 않는 클라이언트로 확인하라. openssl s_client -connect host:443 -showcerts는 서버가 실제로 보낸 모든 인증서를 찍는다. 거기 리프만 보이면, 자물쇠가 아무리 초록이어도 체인은 불완전하다. 정직한 체인 확인은 모두 같은 방식으로 작동한다: 브라우저가 당신 모르게 보낸 몇 번의 친절한 HTTP 요청 뒤 재구성할 수 있는 것이 아니라, 서버가 와이어에 실제로 실어 보내는 것을 읽는다.
당신 인증서 체인의 올바름은 당신 서버가 보내는 것의 속성이다, 끝. 당신 브라우저가 당신에게 보여주는 것의 속성이 아니다 — 그리고 그 둘 사이의 틈이, 브라우저가 아닌 무언가가 연결을 시도하는 날까지, 이 버그가 즐겁게 사는 곳이다.