TL;DR
- 인증서를 받은 컴퓨터는 발급자를 따라 인증서 체인을 올라가 자기 신뢰 저장소에 있는 루트 CA에 닿으면 인증서를 믿는다.
- OpenSSL error 19
self-signed certificate in certificate chain은 서버가 보낸 체인 맨 위의 self-signed 루트 CA가 내 신뢰 저장소에 없다는 뜻이다.- 사내망의 TLS 인스펙션 장비처럼 사설 루트 CA로 다시 서명해 루트 CA까지 함께 보내면 사설 루트 CA를 따로 설치하지 않은 기기에서 error 19가 난다.
- 해결하려면 서버가 보낸 체인 맨 위의 루트 CA를 내 신뢰 저장소에 추가한다.
- 추가하기 전에 지문을 비교해 진짜 회사 루트 CA가 맞는지 확인한다.
인증서 체인과 self-signed 에러
curl이나 Python으로 HTTPS 주소에 접속하다 보면 다음 에러를 만날 때가 있다.
curl: (60) SSL certificate problem: self-signed certificate in certificate chain에러 문구만 보면 상대 서버의 인증서에 결함이 있는 것처럼 보인다. 하지만 같은 주소가 다른 컴퓨터에서는 문제없이 열리기도 한다. 두 컴퓨터는 똑같은 인증서를 받지만 각자 믿는 루트 CA 목록이 달라 검사 결과도 다르게 나온다.
인증서 체인과 신뢰 저장소
HTTPS 사이트는 요청을 보낸 컴퓨터에 "나는 example.com이다"라는 내용이 담긴 인증서를 보낸다. 인증서는 누구나 만들 수 있으므로 받는 쪽은 누가 발급했는지를 보고 믿을지 정한다. 인증서를 발급하는 기관을 CA(Certificate Authority, 인증 기관)라고 하며 Let's Encrypt, DigiCert, Amazon 같은 곳이 있다. CA가 인증서를 발급한다는 것은 인증서에 자기 서명을 넣는다는 뜻이다.
인증서마다 주인(subject)과 발급자(issuer)가 적혀 있고 발급자의 인증서에도 다시 발급자가 적혀 있다. 발급자를 따라 이어진 인증서 묶음이 인증서 체인이다.
| depth | 인증서 | 발급자 |
|---|---|---|
| 0 | example.com (서버 인증서) | 중간 CA |
| 1 | 중간 CA | 루트 CA |
| 2 | 루트 CA | 루트 CA 자신 |
체인 맨 끝의 CA를 루트 CA라고 한다. 루트 CA는 위에 발급해 줄 기관이 없어 자기 인증서를 스스로 발급하므로 루트 CA 인증서가 self-signed인 것은 정상이다.
실제 사이트에 접속하면 체인이 이렇게 보인다(www.naver.com, 2026년 10월 기준).
$ echo | openssl s_client -connect www.naver.com:443 -showcerts 2>/dev/null | grep -E "^ *([0-9]+ )?[si]:"
0 s:C = KR, ST = Gyeonggi-do, L = Seongnam-si, O = NAVER Corporation, CN = *.naver.com
i:C = US, O = DigiCert Inc, CN = DigiCert Global G2 TLS RSA SHA256 2020 CA1
1 s:C = US, O = DigiCert Inc, CN = DigiCert Global G2 TLS RSA SHA256 2020 CA1
i:C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert Global Root G2서버는 두 장을 보낸다. 0번 서버 인증서의 발급자가 1번 중간 CA의 주인이다. 1번의 발급자인 루트 CA DigiCert Global Root G2는 서버가 보내지 않는다.
서버는 보통 self-signed인 루트 CA 인증서를 빼고 보낸다. 받는 쪽 신뢰 저장소에 이미 들어 있기 때문이다.1 루트 CA까지 함께 보내는 서버도 있지만 보낸 루트 CA가 내 신뢰 저장소에 있으면 문제없이 통과한다.
신뢰 저장소(trust store)는 믿을 만한 루트 CA의 목록이고 인증서를 받아서 검사하는 클라이언트(먼저 요청을 보낸 쪽)에 있다.
신뢰 저장소는 OS를 설치할 때 함께 들어오며 인증서와 달리 기기 밖으로 나가지 않는다. Ubuntu는 /etc/ssl/certs/ca-certificates.crt 한 파일에 모아 둔다.2 Windows는 인증서 관리자(certmgr.msc)의 "신뢰할 수 있는 루트 인증 기관"에 둔다.
클라이언트는 발급자를 따라 체인을 올라가 신뢰 저장소에 있는 루트 CA에 닿으면 인증서를 믿는다. 검증할 때는 유효기간과 도메인 이름도 함께 본다.
error 19의 뜻
curl이나 Python처럼 OpenSSL로 인증서를 검증하는 도구는 실패 이유를 번호와 문구로 알려 준다. error 19는 서버가 보낸 인증서로 체인을 맨 위까지 이었는데 신뢰 저장소에서 믿을 루트를 찾지 못했다는 뜻이다.3
문구의 self-signed는 체인 맨 위의 루트 CA를 가리킨다. 루트 CA는 원래 self-signed이므로 문제는 해당 루트가 내 신뢰 저장소에 없다는 데 있다. 같은 체인도 루트 CA를 가진 클라이언트에서는 통과하고 없는 클라이언트에서는 error 19가 난다.
self-signed와 함께 자주 보는 에러는 세 가지다.3
| 번호 | 문구 | 뜻 |
|---|---|---|
| 18 | self-signed certificate | 서버 인증서 자체가 self-signed이고 신뢰 저장소에 없다 |
| 19 | self-signed certificate in certificate chain | 서버가 루트까지 보냈는데 보낸 루트가 신뢰 저장소에 없다 |
| 20 | unable to get local issuer certificate | 체인을 잇는 데 필요한 발급자 인증서를 찾지 못했다 |
루트가 신뢰 저장소에 없는 같은 상황이라도 서버가 루트를 체인에 넣어 보내면 19, 넣지 않으면 20이 난다. 20은 서버가 중간 CA를 빠뜨렸을 때도 난다. OpenSSL 1.1.1 이하는 하이픈 없이 self signed certificate in certificate chain으로 출력한다.4
사설 루트 CA
OS와 브라우저의 신뢰 저장소에 처음부터 들어 있는 루트 CA를 공인 루트 CA라고 한다. 브라우저와 OS를 만드는 회사의 심사와 정기 감사를 통과해야 기본 목록에 실리고 목록은 OS나 브라우저 업데이트로 바뀐다.5
회사나 보안 프로그램이 직접 만들어 운영하는 루트 CA는 사설 루트 CA라고 한다. 누구나 만들 수 있고 심사를 거치지 않아 기본 목록에 없으므로 믿을 기기에 따로 설치해야 한다.
평소 여는 사이트는 공인 CA의 인증서를 쓰므로 사설 루트 CA를 만날 일이 드물다. 그런데 누군가 중간에서 HTTPS 내용을 검사하면 사설 루트 CA가 등장한다. 검사하는 쪽이 원래 인증서 대신 자기 사설 루트 CA로 다시 서명한 인증서를 내 컴퓨터에 보내기 때문이다.67
| 다시 서명하는 쪽 | 검사하는 통신 |
|---|---|
| 회사 보안 장비 | 사내망에서 밖으로 나가는 HTTPS |
| 클라우드 보안 서비스 | 서비스를 거치도록 설정된 직원 기기의 HTTPS (접속 위치와 관계없음) |
| HTTPS 검사 기능을 켠 백신 | 백신이 설치된 기기의 HTTPS |
셋 모두 다시 서명한 인증서가 통과하도록 검사 대상 기기에 자기 루트 CA를 설치해 둔다. 설치해 두지 않은 기기에서는 같은 사이트도 검증에 실패한다.
사내망의 TLS 인스펙션
사설 루트 CA를 만나는 대표적인 곳이 사내망이다. 회사 보안 장비가 회사 안에서 밖으로 나가는 HTTPS 연결의 내용을 검사하는 방식을 TLS 인스펙션이라고 한다. 악성 파일 다운로드나 회사 자료 유출을 걸러 내려는 목적이다. HTTPS는 내용이 암호화돼 있어 그대로 중계해서는 볼 수 없으므로 장비가 연결을 둘로 나눈다.
- 직원 컴퓨터 ↔ 장비: 장비가 직원이 접속하려던 사이트인 것처럼 연결을 받는다.
- 장비 ↔ 외부 사이트: 장비가 클라이언트가 되어 외부 사이트에 따로 접속하고 진짜 인증서를 검사한다.
장비는 두 연결 사이에서 내용을 풀어 검사하고 다시 암호화해 넘긴다. 진짜 인증서와 짝을 이루는 개인키(private key)는 해당 사이트만 갖고 있으므로 장비는 회사 CA로 같은 도메인 이름의 인증서를 새로 발급해 직원 컴퓨터에 내민다. 구조는 중간자 공격(MITM)과 같다.

클라이언트가 받는 체인은 서버 인증서(장비가 발급) ← 회사 루트 CA 2단이거나 회사 중간 CA를 하나 더 낀 3단이다. 회사 루트 CA는 회사가 직접 만든 사설 루트 CA라서 OS 기본 신뢰 저장소에는 없다. 장비가 회사 루트 CA까지 체인에 넣어 보내면 error 19, 넣지 않으면 error 20이 난다.
회사가 관리하는 노트북에는 회사 루트 CA가 미리 설치되어 있어 문제가 없다. 하지만 직접 설치한 서버나 개인 노트북처럼 회사가 관리하지 않는 기기에는 회사 루트 CA가 없어 같은 주소에서 검증에 실패한다. 같은 인증서를 받아도 신뢰 저장소에 회사 루트 CA가 있는 기기만 통과한다.
진단과 해결
아래 명령은 에러가 난 Ubuntu 서버에서 실행하고 회사 루트 CA를 예로 든다. 다른 사설 루트 CA도 순서는 같다. 2단계는 상대 서버가 루트 CA까지 보내서 error 19가 났을 때 쓴다. error 20이면 상대 서버가 루트 CA를 보내지 않은 것이므로 루트 CA를 배포한 쪽에서 파일을 받아 3단계부터 진행한다.
1. 막힌 루트 CA 찾기
서버가 보낸 체인과 검증 결과를 출력해 어느 인증서에서 검증이 멈췄는지 본다.
echo | openssl s_client -connect <host>:443 -showcerts 2>&1 | \
grep -E "depth=|verify error|Verify return code|^ *([0-9]+ )?[si]:"Verify return code: 0 (ok)이면 검증을 통과한 것이다.verify error:num=19가 나오면 바로 위depth=줄에 적힌 인증서가 신뢰 저장소에 없는 루트 CA다. error 19는 이 루트 CA가 self-signed라는 뜻이고 아래 인증서 목록에서는 주인(s:)과 발급자(i:)가 같게 보인다.- 루트의 주인(
s:)에 적힌 이름으로 어느 쪽의 사설 루트 CA인지 짐작할 수 있다. 이름은 누구나 똑같이 붙일 수 있으므로 진짜인지는 3단계에서 지문으로 확인한다.

2. 루트 CA 인증서 저장
-showcerts는 받은 인증서를 PEM 형식(-----BEGIN CERTIFICATE-----로 시작하는 텍스트)으로 모두 출력하며 체인 맨 위 인증서는 보통 마지막에 나온다. 마지막 인증서만 chain-root.crt 파일로 저장한다.
echo | openssl s_client -connect <host>:443 -showcerts 2>/dev/null | \
awk '/BEGIN CERTIFICATE/{c=""} {c=c $0 "\n"} /END CERTIFICATE/{last=c} END{printf "%s", last}' \
> chain-root.crt저장한 파일이 1단계에서 찾은 루트 CA인지는 주인과 발급자를 출력해 확인한다. 두 줄 모두 1단계의 루트 CA 이름이면 맞게 저장된 것이다.
openssl x509 -in chain-root.crt -noout -subject -issuer3. 지문으로 진짜 회사 루트 CA인지 확인
신뢰 저장소에 넣은 루트 CA로 서명한 인증서는 어느 사이트 것이든 경고 없이 통과한다. 회사 것이 아닌 루트 CA가 들어가 있으면 루트 CA를 만든 쪽이 은행 이름으로 인증서를 발급해 가짜 서버에 붙여도 내 컴퓨터는 진짜 은행으로 믿는다. 넣기 전에 저장한 인증서의 SHA-256 지문(fingerprint, 인증서마다 다른 고유 번호)을 확인한다.
openssl x509 -in chain-root.crt -noout -fingerprint -sha256출력된 지문을 진짜 회사 루트 CA의 지문과 비교한다. 진짜 지문은 회사 노트북에 설치된 회사 루트 CA에서 확인하거나 IT 부서에 요청해 받는다. Windows 인증서 관리자에 보이는 지문(thumbprint)은 SHA-1이므로 이때는 -sha256 대신 -sha1로 출력해 비교한다.8 값이 다르면 등록하지 않는다.

4. 신뢰 저장소에 등록
Ubuntu에서는 PEM 형식에 .crt 확장자인 파일을 /usr/local/share/ca-certificates/ 아래에 두고 update-ca-certificates를 실행한다.2 지문을 확인한 파일을 회사 루트 CA 이름으로 복사한다.
sudo cp chain-root.crt /usr/local/share/ca-certificates/company-root-ca.crt
sudo update-ca-certificates출력에 1 added가 나오면 신뢰 저장소에 등록된 것이다.
5. 다시 접속해 확인
1단계 명령을 다시 실행해 Verify return code: 0 (ok)가 나오면 OpenSSL의 체인 검증을 통과한 것이다. 처음 실패했던 curl이나 Python 명령도 다시 실행해 확인한다.
도구마다 신뢰 저장소가 다를 수 있다. curl과 apt는 시스템 저장소를 쓰지만 Java는 자체 키 저장소(keystore)를, Python requests는 기본으로 certifi 패키지의 목록을 쓴다.9 snap 앱은 시스템 저장소에 넣은 인증서를 반영하지 않을 수 있다.10 시스템에 넣은 뒤에도 특정 도구만 실패하면 도구별 저장소를 따로 확인한다.
Connections
- [[TLS 종단과 신뢰 경계]] — 사내 TLS 인스펙션은 CDN의 TLS 종단과 구조가 같고 루트 CA를 설치해야 해서 존재가 드러난다
- [[SSH 공개키 인증 (key-based authentication)]] — 같은 공개키 암호를 쓰지만 SSH는 CA 체인 없이 상대 공개키를 직접 등록한다
- [[Cloudflare Tunnel]] — Cloudflare 서버(엣지)가 공인 CA 인증서를 대신 관리하므로 접속하는 기기에 CA를 따로 넣지 않아도 된다
- [[EDR (Endpoint Detection and Response)]] — TLS 인스펙션과 함께 사내 보안 통제를 이루는 엔드포인트 쪽 수단
- [[OAuth 인증 플로우]] — TLS가 보증하는 전송 계층 위에서 동작하는 인증 프로토콜
- [[Secure Boot]] — 서명 사슬로 신뢰를 쌓는 같은 원리를 부팅 단계에 쓴다
Discussion
Comments
댓글은 승인 후 공개됩니다.