크롬은 되는데 파이어폭스만 인증서 오류 — SEC_ERROR_UNKNOWN_ISSUER 원인
“크롬에서는 잘 되는데 파이어폭스에서만 인증서 오류가 납니다.”
인증서 문제 중 가장 자주 접수되는 형태이고, 가장 자주 오진되는 형태이기도 하다. 서버 설정을 아무리 들여다봐도 이상이 없어 보이기 때문이다. 실제로 서버 설정은 이상이 없는 경우가 많다. 문제는 서버가 무엇을 보내느냐가 아니라, 받는 쪽이 그것을 어떻게 채워 넣느냐에 있다.
체인 검증은 누가 하는가
TLS 핸드셰이크에서 서버는 인증서 목록을 보낸다. 보통 이런 구성이다.
[0] 서버 인증서 (leaf) CN = example.com
[1] 중간 인증서 CN = R11, O = Let's Encrypt
루트 인증서는 보내지 않는 것이 정상이다. 루트는 클라이언트가 이미 신뢰하고 있어야 의미가 있고, 서버가 보내준 루트를 믿는다면 그건 검증이 아니다.
그래서 검증은 이렇게 진행된다. 클라이언트는 서버가 준 leaf에서 출발해, 발급자를 따라 올라가며 자기 트러스트 스토어에 있는 루트에 닿을 때까지 경로를 만든다. 닿으면 신뢰, 못 닿으면 오류다.
여기서 갈린다. 트러스트 스토어가 클라이언트마다 다르다.
| 클라이언트 | 트러스트 스토어 |
|---|---|
| Chrome / Edge (Windows) | Windows 인증서 저장소 |
| Chrome (macOS) | 시스템 키체인 |
| Safari | 시스템 키체인 |
| Firefox | 자체 스토어 (OS와 무관) |
| curl / openssl (Linux) | /etc/ssl/certs (ca-certificates 패키지) |
| Java 애플리케이션 | JDK의 cacerts |
Firefox가 유독 자주 튀는 이유가 이것이다. OS에 루트를 설치해도 Firefox는 모른다.
사내 CA를 쓰는 환경에서 “크롬만 되는” 현상은 대부분 여기서 나온다.
(Windows 그룹 정책으로 배포한 사내 루트도 마찬가지로 Firefox엔 안 들어간다.
security.enterprise_roots.enabled 를 켜야 OS 스토어를 참조한다.)
원인 1 — 중간 인증서 누락 + AIA 자동 보정
가장 흔한 케이스다. 서버가 leaf만 보내고 중간 인증서를 빼먹었다.
[0] 서버 인증서 (leaf) CN = example.com
↑ 중간 인증서 없음
이러면 모든 클라이언트가 실패해야 정상인데, 실제로는 크롬과 엣지는 멀쩡히 접속된다. leaf 인증서 안에 발급자를 받아올 주소가 들어 있기 때문이다.
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -text | grep -A2 "Authority Information Access"
Authority Information Access:
OCSP - URI:http://r11.o.lencr.org
CA Issuers - URI:http://r11.i.lencr.org/
이 CA Issuers 주소로 클라이언트가 접속 도중 직접 중간 인증서를 받아오는 동작을
AIA fetching이라 한다. 데스크톱 크롬·엣지·사파리가 이걸 한다.
Firefox는 하지 않는다. 의도적인 선택인데, 핸드셰이크가 느려지고 사용자가 어떤 사이트에 접속 중인지가 CA에 노출되기 때문이다. 대신 다른 방법을 쓴다. CA들이 CCADB에 공개한 중간 인증서를 Remote Settings로 미리 내려받아 프로파일에 두는 방식이다(2020년 11월 도입). 그래서 결과만 보면 Firefox도 대체로 통과한다.
여기서 증상이 갈라진다.
| 상황 | 크롬·엣지·사파리 | Firefox | curl · Java |
|---|---|---|---|
| 공개 CA, 중간 인증서 누락 | 정상 (AIA로 획득) | 대체로 정상 (사전 배포분) | 실패 |
| 사내 CA·사설 CA | 정상 (AIA 가능 시) | 실패 | 실패 |
| 발급 직후 신규 중간 인증서 | 정상 | 실패 가능 (배포 전) | 실패 |
| Android 크롬 | 실패 (AIA 안 함) | — | — |
- Firefox —
SEC_ERROR_UNKNOWN_ISSUER curl—unable to get local issuer certificate- 자바 —
PKIX path building failed
공통점은 하나다. 서버는 처음부터 틀려 있었고, 일부 클라이언트가 덮어주고 있었을 뿐이다. 브라우저에서 잘 된다고 정상이라 판단하면 API 클라이언트·모바일 앱·배치 작업이 조용히 깨진다. 사설 CA를 쓰는 환경이라면 Firefox가 가장 먼저 터진다 — 사전 배포 대상이 공개 CA뿐이기 때문이다.
원인 2 — 캐시된 중간 인증서
“제 PC에서는 되는데요”의 정체다.
클라이언트는 한 번 받아온 중간 인증서를 캐시해 재사용한다. 같은 CA를 쓰는 다른 사이트를 방문했다면 그 중간 인증서가 이미 로컬에 있고, 그러면 서버가 안 보내줘도 검증에 성공한다.
즉 인증서를 자주 다루는 사람의 PC일수록 이 문제를 못 본다. 담당자 PC에서는 정상, 고객사 신규 PC에서는 오류. 이 조합이 나오면 중간 인증서 누락을 먼저 의심하면 된다.
검증하려면 캐시가 없는 환경에서 봐야 한다. 가장 확실한 건 서버 쪽에서 직접 확인하는 것이다.
# 서버가 실제로 보내는 것만으로 검증되는지
openssl s_client -connect example.com:443 -servername example.com \
-verify_return_error </dev/null 2>&1 | grep -E "Verify return code|verify error"
Verify return code: 0 (ok)
0 (ok) 이 아니면 서버가 보내는 체인이 불완전하다는 뜻이다. 브라우저 결과와
무관하게 이쪽이 사실이다.
원인 3 — 만료된 교차 서명 루트
체인이 완전한데도 특정 기기에서만 실패하는 경우가 있다. 오래된 안드로이드, 구형 결제 단말, 업데이트가 멈춘 서버가 해당된다.
루트 인증서도 만료된다. 새 루트가 신뢰를 얻기까지는 몇 년이 걸리기 때문에, CA는 그동안 구 루트로 교차 서명한 중간 인증서를 함께 배포한다. 그러면 새 루트를 아는 기기는 짧은 경로로, 모르는 기기는 구 루트를 거치는 긴 경로로 검증할 수 있다.
문제는 구 루트가 만료되는 날 발생한다. 2021년 Let’s Encrypt의 DST Root CA X3
만료가 대표적이다. 서버도 인증서도 그대로였는데 구형 안드로이드에서 일제히
접속이 끊겼다. 트러스트 스토어 업데이트가 멈춘 기기는 새 루트를 모르고, 알던
루트는 만료됐기 때문이다.
이건 서버 설정으로 완전히 해결되지 않는다. 어떤 체인을 내보낼지 선택해서 어느 쪽을 살릴지 고르는 문제에 가깝다.
진단 순서
1. 서버가 보내는 체인 전체를 본다.
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null \
| grep -E "^ [0-9]|subject=|issuer="
0 s:CN = example.com
i:C = US, O = Let's Encrypt, CN = R11
1 s:C = US, O = Let's Encrypt, CN = R11
i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
각 항목의 i:(issuer)가 바로 다음 항목의 s:(subject)와 일치해야 한다.
끊기는 지점이 누락된 인증서다. 마지막 i: 가 공개 루트면 정상이다.
2. 순서를 확인한다. leaf가 반드시 [0]이어야 하고, 위로 올라가는 순서여야
한다. 순서가 뒤집혀도 관대한 클라이언트는 통과시키지만 엄격한 쪽은 거부한다.
3. 클라이언트별로 나눠 본다.
# 시스템 트러스트 스토어 기준
curl -sSI https://example.com >/dev/null && echo "curl OK"
# 서버가 보낸 것만으로 검증 (AIA·캐시 배제)
openssl s_client -connect example.com:443 -servername example.com \
-verify_return_error </dev/null >/dev/null 2>&1 && echo "체인 자체 OK"
curl은 되는데 -verify_return_error가 실패하면 로컬 캐시 덕을 보고 있다는 뜻이다.
해결
대부분은 fullchain을 배포하는 것으로 끝난다. leaf만 들어 있는 파일이 아니라 중간 인증서까지 이어 붙인 파일을 지정해야 한다.
Nginx — ssl_certificate 에 fullchain을 지정한다. ssl_trusted_certificate는
OCSP stapling 검증용이지 클라이언트에 보내는 체인이 아니다. 혼동하기 쉽다.
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # leaf + 중간
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
Apache — 2.4.8 이상은 SSLCertificateFile 에 fullchain을 그대로 넣으면 된다.
그 이전 버전은 SSLCertificateChainFile 로 분리해야 한다.
SSLCertificateFile /etc/ssl/certs/fullchain.pem
SSLCertificateKeyFile /etc/ssl/private/privkey.pem
IIS — 파일 개념이 아니라 저장소 개념이다. 중간 인증서를 로컬 컴퓨터 → 중간 인증 기관 에 설치해야 하고, 여기 없으면 IIS는 체인을 못 보낸다.
Import-Certificate -FilePath .\intermediate.crt `
-CertStoreLocation Cert:\LocalMachine\CA
교체 후에는 반드시 리로드하고, 리로드 뒤에 다시 검증해야 한다. 파일만 바꾸고 리로드를 빼먹으면 디스크는 새 체인, 서빙은 옛 체인이 된다. 이건 앞 글에서 다룬 “파일과 서빙의 불일치”와 같은 함정이다.
정리
- 브라우저마다 다르게 보이는 건 서버가 다르게 응답해서가 아니라, 클라이언트마다 검증 방식이 달라서다
- 크롬에서 정상인 것은 정상의 증거가 아니다. AIA fetching과 캐시가 결함을 가려준다
- 판정 기준은 하나면 된다 —
openssl s_client -verify_return_error가Verify return code: 0 (ok)를 내는가 - 고치는 방법은 대부분 fullchain 배포. 어렵지 않은데, 찾는 데 시간이 걸리는 종류의 문제다
- 교체와 리로드를 실수 없이 하는 절차는 Nginx 인증서 교체와 무중단 리로드 참고