하나의 경고, 서로 다른 두 문제

인증서가 “맞지 않는다”는 경고 뒤에는 성격이 전혀 다른 두 문제 중 하나가 있습니다.

  • 호스트명 불일치. 인증서 자체는 유효하고 신뢰할 수 있지만, 접속한 이름이 인증서에 없습니다. 크롬은 NET::ERR_CERT_COMMON_NAME_INVALID, 파이어폭스는 SSL_ERROR_BAD_CERT_DOMAIN, 라이브러리들은 “hostname mismatch”, “certificate doesn’t match host”, “no alternative certificate subject name matches”라고 말합니다.
  • 체인 불완전. 이름은 맞지만 서버가 중간 인증서를 보내지 않아서, 클라이언트가 이 인증서를 자기가 신뢰하는 루트와 연결하지 못합니다. 크롬은 (뭔가를 보여 준다면) NET::ERR_CERT_AUTHORITY_INVALID, curl은 unable to get local issuer certificate, Java는 PKIX path building failed, .NET은 PartialChain이라고 합니다.

해결책은 겹치지 않습니다. 이름을 더 넣어 재발급해도 빠진 중간 인증서는 해결되지 않고, 중간 인증서를 설치해도 빠진 이름은 해결되지 않습니다. 무엇이든 바꾸기 전에 어느 쪽인지부터 확인하세요.

두 질문에 한 번에 답하는 명령

openssl s_client -connect www.example.com:443 -servername www.example.com -showcerts </dev/null

-servername에는 항상 실패하는 바로 그 이름을 넣으세요. 빠뜨리면 서버가 기본 인증서를 돌려줄 수 있고, 엉뚱한 인증서를 붙잡고 진단하게 됩니다.

출력에서 세 곳을 봅니다.

서버가 보낸 체인(위쪽):

Certificate chain
 0 s:CN = www.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

s:는 주체(subject), i:는 발급자(issuer)입니다. 정상 체인은 계단 모양입니다. 각 인증서의 발급자가 다음 줄 인증서의 주체입니다. 0번 줄만 있다면 중간 인증서가 빠진 것입니다.

검증 결과(아래쪽):

Verify return code: 0 (ok)

중간 인증서가 빠졌다면 이렇게 나옵니다.

Verify return code: 21 (unable to verify the first certificate)

leaf 인증서에 든 이름(호스트명 불일치가 의심될 때):

openssl s_client -connect www.example.com:443 -servername www.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -ext subjectAltName

입력한 이름이 Subject Alternative Name 목록에 없다면(와일드카드는 정확히 한 단계만 대신합니다) 호스트명 불일치입니다. 이 경우는 흔한 원인 7가지를 정리한 별도 가이드가 있습니다. NET::ERR_CERT_COMMON_NAME_INVALID를 보세요. 이 페이지의 나머지는 체인 이야기입니다.

SSL 점검은 같은 일을 우리 네트워크 밖에서 합니다. 서울에 있는 서버가 실제 핸드셰이크를 해서 실제로 전송되는 인증서를 읽고, 표준 Mozilla 루트 목록으로 체인을 검증합니다. 빠진 중간 인증서를 대신 내려받지 않기 때문에, 크롬이 덮어 주는 방식이 아니라 curl이나 안드로이드 앱이 보는 방식 그대로 깨진 체인을 보여 줍니다. 인증서 폐기(revocation) 여부는 확인하지 않습니다.

크롬에서는 되고 나머지에서는 안 되는 이유

체인 누락이 헷갈리는 이유가 이것입니다. 크롬으로 사이트를 열면 자물쇠가 뜨고 아무 문제가 없습니다. 그런데 결제사 웹훅이 실패하고, 안드로이드 앱이 연결을 못 하고, 모니터링이 빨간불을 켜고, 동료의 curl이 오류를 냅니다.

모든 인증서에는 Authority Information Access(AIA) 확장이 있고, 여기에 발급자 인증서를 받을 수 있는 URL이 들어 있습니다. 서버가 중간 인증서를 보내지 않으면:

  • 크롬·엣지·사파리는 빈자리를 알아채고 AIA URL에서 중간 인증서를 받아 와 그대로 진행합니다. 사용자는 아무것도 모릅니다.
  • 파이어폭스는 AIA URL을 따라가지 않지만, 현재 데스크톱 버전은 공인 CA의 중간 인증서를 모두 내장하고 있고 한 번 본 중간 인증서도 기억합니다. 그래서 파이어폭스에서도 대개 열리고, “다른 브라우저로 확인해 보기”가 여기서는 소용이 없습니다.
  • curl, OpenSSL, Python, Node.js, Java, 리눅스의 Go, 안드로이드의 TLS 스택은 이런 일을 하지 않습니다. 서버가 보낸 것과 자기 루트 저장소만 믿습니다. 중간 인증서가 없으면 그대로 실패합니다.

결과적으로 데스크톱 브라우저에서는 보이지 않고 API 클라이언트와 모바일 앱에는 치명적인 버그가 됩니다. 브라우저만 보고 “인증서는 정상”이라고 결론 내리지 마세요. openssl s_client나 외부 점검 도구로 확인해야 합니다.

클라이언트별로 실패는 이렇게 보입니다.

curl: (60) SSL certificate problem: unable to get local issuer certificate

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
unable to get local issuer certificate

javax.net.ssl.SSLHandshakeException: PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException:
unable to find valid certification path to requested target

Error: unable to verify the first certificate      (Node.js, UNABLE_TO_VERIFY_LEAF_SIGNATURE)

The remote certificate is invalid because of errors in the certificate chain: PartialChain   (.NET)

.NET은 예외가 하나 있습니다. 윈도우와 리눅스 모두에서 빠진 중간 인증서를 스스로 내려받을 수 있습니다. 그래서 .NET의 PartialChain은 그 다운로드까지 막혔거나(외부 HTTP 통신이 막힌 컨테이너나 서버), 체인 끝이 그 컨테이너에 설치되지 않은 사설 CA 루트인 경우가 많습니다. 먼저 openssl s_client를 돌려 보고, 서버가 전체 체인을 보내는데도 .NET이 PartialChain을 낸다면 클라이언트 쪽 신뢰 저장소 문제입니다.

같은 오류는 클라이언트가 원인일 때도 나옵니다. ca-certificates 패키지가 빠진 경량 Docker 이미지, 자기 루트로 트래픽을 다시 서명하는 회사 프록시가 대표적입니다. 평범한 PC에서 openssl s_client를 돌렸을 때 체인이 다 나오고 Verify return code: 0이면 서버는 정상입니다.

서버 쪽 흔한 원인

1. fullchain.pem 대신 cert.pem을 설정함

압도적으로 흔한 경우입니다. certbot은 /etc/letsencrypt/live/example.com/에 파일 네 개를 만듭니다.

파일내용
cert.pemleaf 인증서만
chain.pem중간 인증서만
fullchain.pemleaf + 중간 인증서
privkey.pem개인 키

nginx는 한 파일에 전부 들어 있기를 기대합니다.

ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

include된 파일까지 포함해 nginx가 실제로 읽는 경로를 확인합니다.

sudo nginx -T 2>/dev/null | grep -n 'ssl_certificate'

Apache 2.4.8 이상도 SSLCertificateFile에 전체 체인을 받습니다. SSLCertificateChainFile은 deprecated입니다. 그보다 오래된 Apache라면 SSLCertificateFile에 cert.pem, SSLCertificateChainFile에 chain.pem을 줍니다.

상용 CA 인증서는 보통 내 인증서와 “CA bundle” 또는 “intermediate” 파일이 zip으로 옵니다. leaf를 앞에 두고 직접 합칩니다.

cat www_example_com.crt ca-bundle.crt > fullchain.pem

같은 실수가 나는 다른 곳들:

  • HAProxy: crt에 지정한 파일에 leaf, 중간 인증서, 개인 키가 모두 있어야 합니다.
  • Kubernetes: TLS 시크릿의 tls.crt에 leaf만이 아니라 전체 체인이 들어가야 합니다.
  • AWS ACM 가져오기, 대부분의 로드밸런서·CDN 업로드 화면: “인증서 체인” 칸이 따로 있습니다. 비워 두면 leaf만 올린 셈입니다.
  • Node.js: https.createServer({ cert, key })는 cert에 든 것만 보냅니다. 여기서 fullchain.pem을 읽으세요.
  • IIS 등 윈도우 서버: 윈도우가 로컬 인증서 저장소로 만들 수 있는 체인을 보냅니다. 중간 인증서가 컴퓨터의 “중간 인증 기관” 저장소에 없으면(보통 PFX를 중간 인증서 없이 가져온 경우) leaf만 보냅니다.

2. 체인 순서가 틀림

leaf가 맨 앞, 그다음이 leaf를 서명한 중간 인증서, 그다음이 그 위입니다. 거꾸로 합치면(cat ca-bundle.crt www_example_com.crt) nginx는 개인 키를 파일의 첫 인증서, 즉 중간 인증서와 짝지으려다 로드를 거부합니다.

SSL_CTX_use_PrivateKey("/etc/nginx/ssl/example.key") failed
(SSL: error:05800074:x509 certificate routines::key values mismatch)

(구버전 OpenSSL에서는 error:0B080074:x509 certificate routines:X509_check_private_key:key values mismatch로 나옵니다.) leaf는 맨 앞인데 중간 인증서끼리 순서만 섞인 경우 최신 클라이언트는 대부분 알아서 맞추지만, 오래된 클라이언트는 그렇지 않습니다. 굳이 기대할 이유가 없습니다.

번들 안의 인증서를 순서대로 봅니다.

openssl crl2pkcs7 -nocrl -certfile fullchain.pem | openssl pkcs7 -print_certs -noout

계단을 확인하세요. 첫 항목의 issuer가 둘째 항목의 subject여야 하고, 그다음도 마찬가지입니다.

3. 인증서와 개인 키가 짝이 아님

갱신이나 재발급 뒤 새 인증서는 복사했는데 키는 예전 요청의 것이 남았거나, 그 반대인 경우입니다. 서버는 보통 위의 key values mismatch 오류와 함께 시작이나 리로드를 거부합니다.

인증서 안의 공개 키와 개인 키에서 뽑은 공개 키를 비교합니다. RSA와 ECDSA 키 모두에 쓸 수 있습니다.

openssl x509 -in cert.pem -noout -pubkey | openssl sha256
openssl pkey -in privkey.pem -pubout | openssl sha256

두 해시가 같아야 합니다. 예전에 많이 쓰던 modulus 비교(openssl x509 -noout -modulus와 openssl rsa -noout -modulus)는 RSA 키에서만 됩니다.

위험한 건 그다음입니다. systemctl reload nginx가 실패하면 nginx는 예전 설정과 예전 인증서로 계속 돕니다. 예전 인증서가 만료되는 날까지는 아무 문제 없어 보입니다. 리로드 전에는 항상 nginx -t(또는 apachectl configtest)를 돌려 결과를 읽고, 리로드 뒤에는 journalctl -u nginx를 확인하세요.

4. 파일은 고쳤는데 다른 곳이 인증서를 내보냄

올바른 파일을 고치고 리로드했는데 openssl s_client에 여전히 예전 체인이 나온다면, 실제로 TLS를 종료하는 곳이 어디인지 보세요.

  • 서버 앞의 CDN, 로드밸런서, 리버스 프록시가 자기 인증서를 내보냅니다. 방문자에게는 원 서버의 파일이 상관없습니다. 인증서를 업로드한 그곳에서 체인을 고쳐야 합니다.
  • 한 이름 뒤에 서버가 여러 대인데 한 대만 고쳤습니다. IP마다 openssl s_client -connect 203.0.113.10:443 -servername www.example.com으로 따로 확인하세요.
  • DNS가 생각과 다른 곳을 가리킵니다. DNS 조회로 지금 어디로 해석되는지 확인하세요.
  • server_name이 SNI와 맞지 않아 다른 server 블록이 응답했고, 그래서 다른 ssl_certificate가 쓰였습니다.

5. 중간 인증서 자체가 낡음

CA는 중간 인증서를 주기적으로 바꿉니다. 몇 년 전에 받아 둔 중간 인증서를 새 leaf와 계속 합치고 있다면 체인이 더는 맞물리지 않습니다. leaf의 발급자가 중간 인증서의 주체와 다릅니다. 항상 현재 인증서와 함께 받은 체인을 쓰세요(certbot의 fullchain.pem은 갱신할 때마다 새로 만들어집니다).

leaf만 있다면 AIA URL로 발급자를 찾을 수 있습니다.

openssl x509 -in cert.pem -noout -ext authorityInfoAccess
curl -s http://r11.i.lencr.org/ | openssl x509 -inform DER -out intermediate.pem

둘째 줄의 URL은 예시일 뿐이니, 내 인증서가 출력하는 URL을 쓰세요. 이렇게 받는 파일은 보통 DER 형식이라 -inform DER을 붙입니다.

배포 전에 파일로 확인하기

openssl verify는 엄격한 클라이언트와 같은 방식으로 체인을 만듭니다.

openssl verify -untrusted chain.pem cert.pem

cert.pem: OK가 나오면, 내가 준 중간 인증서만으로 leaf가 시스템 저장소의 루트까지 이어진다는 뜻입니다. 여기서 실패하면 운영 환경에서도 curl과 안드로이드는 실패합니다.

고친 다음에

  • openssl s_client -showcerts를 다시 돌려 인증서가 최소 두 개 보이고 Verify return code: 0 (ok)인지 확인합니다.
  • 그 인증서를 쓰는 모든 호스트명을 확인합니다. server 블록마다 다른 파일을 가리키는 경우가 많습니다.
  • SSL 점검을 다시 돌립니다. 체인 항목이 Untrusted가 아니라 OK로 나와야 합니다.
  • 갱신 훅이나 배포 스크립트가 인증서를 복사한다면 스크립트도 고치세요. 아니면 다음 갱신 때 문제가 돌아옵니다.

관련 오류

내 도메인에서 바로 확인

무료, 가입 불필요. 이 가이드가 다루는 항목을 바로 검사하고 조치 방법을 알려드립니다.