신뢰 저장소는 하나가 아니다
한 줄 요약
폐쇄망의 사내 미러는 거의 다 사설 CA 가 발급한 인증서를 쓴다. 그런데 신뢰 저장소는 하나가 아니다. 운영체제, JVM, 파이썬의 certifi, Node 의 내장 목록이 각자 따로 있다. 한 곳에 넣었다고 다 믿는 것이 아니라서, 어떤 도구가 어느 저장소를 보는지 표로 알고 있어야 한다.
왜 이게 필요했나
사내 미러를 HTTPS 로 세우고 서버에 루트 인증서를 넣었다. curl 은 된다. 그런데 파이썬 배치는 SSLError: CERTIFICATE_VERIFY_FAILED, 프런트엔드 빌드는 UNABLE_TO_VERIFY_LEAF_SIGNATURE, 자바 빌드는 PKIX path building failed 로 죽는다. 흔한 반응은 verify=False, strict-ssl=false, -k 로 검증을 끄는 것이다. 그러면 사내망 안에서 누가 미러 행세를 해도 아무도 모른다. 폐쇄망은 바깥 공격이 적을 뿐이지 안쪽 공격이 없는 곳이 아니다.
사설 루트를 운영체제 저장소에 넣은 뒤에도 도구마다 결과가 갈렸다. 본문이 이 실습 파드에서 재 본 결과를 둘로 묶어 옮겼다. 한 곳에 넣었다고 다 믿는 것이 아니다.
- 운영체제 저장소만으로 통과: curl, 파이썬 urllib, Go, pipupdate-ca-certificates 뒤에 통과했다. pip 가 통과한 것은 이 실습 이미지의 pip 가 우분투가 고친 판이라 운영체제 묶음을 읽기 때문이다.
- 여전히 실패: requests, httpx, Noderequests 와 httpx 는 CERTIFICATE_VERIFY_FAILED, Node 는 UNABLE_TO_VERIFY_LEAF_SIGNATURE 로 실패했다. 자기 묶음(certifi, 내장 목록)을 쓰기 때문이며 REQUESTS_CA_BUNDLE, SSL_CERT_FILE, NODE_EXTRA_CA_CERTS 를 주자 통과했다.
여기서 구분할 것 파이썬 문서가 뒷받침하는 것은 표준 ssl 모듈의 기본 검증 경로가 OpenSSL 의 기본 cafile 과 capath 라는 것까지이다. 나머지 도구가 어느 저장소를 보는지는 각 도구의 문서에 정의돼 있고 여기에는 링크하지 않았다. 이 구분은 본문이 이 실습 파드에서 잰 결과이며 도구 버전에 따라 달라질 수 있다.
잠깐, 예측해 보세요 Node 22.11 빌드 서버에서 운영체제 저장소를 쓰게 하려고 NODE_USE_SYSTEM_CA=1 을 줬는데 효과가 없다. 본문 기준으로 왜일까?
설명 확인 · 채점 없는 자가 점검
이 환경 변수는 v22.19.0 과 v24.6.0 에 들어왔고 이 실습의 node 는 22.11 이라 아직 없다. 같은 일을 하는 --use-system-ca 도 v23.8.0 에 생겼다. 그래서 버전을 먼저 확인하고, 이 버전에서는 NODE_EXTRA_CA_CERTS 로 루트 파일을 더한다.
어떻게 동작하나
CA 와 서버 인증서. 루트 CA 인증서에는 basicConstraints = CA:TRUE 가 있어야 다른 인증서에 서명할 자격이 된다(OpenSSL x509v3_config 문서). 서버 인증서에는 접속할 이름이 subjectAltName 에 DNS: 로 들어가야 한다. RFC 9525 는 commonName 을 이름 확인에 쓰는 것이 더는 유효하지 않다고 적고, Go 는 1.15 부터 CN 을 이름으로 보지 않는다. CN 에만 이름을 적은 인증서는 요즘 도구에서 이름 불일치로 거절된다.
운영체제 저장소. 데비안·우분투는 /usr/local/share/ca-certificates/ 에 넣고 update-ca-certificates 를 돌린다. 매뉴얼은 확장자가 반드시 .crt 여야 한다고 적고, 결과는 한 파일로 이어 붙인 /etc/ssl/certs/ca-certificates.crt 다. 이 명령은 /etc/ca-certificates/update.d/ 의 훅도 돌리는데, 우분투의 ca-certificates-java 가 여기에 훅을 걸어 JVM 저장소(/etc/ssl/certs/java/cacerts)까지 갱신한다. RHEL 계열은 /etc/pki/ca-trust/source/anchors/ 에 넣고 update-ca-trust extract 를 돌린다.
도구마다 보는 곳.
curl 빌드할 때 정해진 파일 저장소(우분투는 운영체제 묶음). --cacert·CURL_CA_BUNDLE 로 바꾼다
파이썬 ssl OpenSSL 기본 경로 = 운영체제 묶음. SSL_CERT_FILE·SSL_CERT_DIR 로 바꾼다
requests certifi 묶음. REQUESTS_CA_BUNDLE(없으면 CURL_CA_BUNDLE) 로 바꾼다. SSL_CERT_FILE 은 읽지 않는다
httpx certifi 묶음. SSL_CERT_FILE·SSL_CERT_DIR 를 따른다
Node 빌드에 들어간 모질라 목록. NODE_EXTRA_CA_CERTS 로 더한다(프로세스 시작 때 한 번 읽는다)
Go 리눅스에서는 운영체제 묶음. SSL_CERT_FILE·SSL_CERT_DIR 로 바꾼다
JVM cacerts(우분투는 운영체제 훅이 채운다). keytool 이나 javax.net.ssl.trustStore
Node 는 문서가 여러 가지를 덧붙인다. NODE_EXTRA_CA_CERTS 는 setuid 로 뜬 프로세스에서는 무시되고, 파일이 없거나 형식이 틀리면 경고 한 번만 내고 넘어간다. 운영체제 저장소를 쓰게 하는 --use-system-ca 는 v23.8.0 에 생겼고(리눅스는 v23.9.0 부터), 같은 일을 하는 환경 변수 NODE_USE_SYSTEM_CA=1 은 v22.19.0·v24.6.0 에 들어왔다. 이 실습의 node 는 22.11 이라 둘 다 없다 — 버전을 먼저 확인하는 이유다.
체인. 현장의 사설 PKI 는 루트 아래 중간 CA 를 두고 서버 인증서는 중간 CA 가 발급하는 경우가 많다. 클라이언트 저장소에는 루트만 있으므로 서버가 자기 인증서와 중간 CA 인증서를 함께 보내야 경로가 이어진다. 서버에 잎 인증서만 걸면 unable to get local issuer certificate 가 난다. 이것은 클라이언트가 아니라 서버 설정의 잘못이다.
현장에서 만나는 모습
이 실습 파드에서 재 본 결과다. 루트를 운영체제 저장소에 넣기 전에는 curl·파이썬 urllib·requests·httpx·Node·pip 가 모두 인증서 검증에서 실패했다. update-ca-certificates 뒤에는 curl·urllib·Go·pip 가 통과했고, requests 와 httpx 는 CERTIFICATE_VERIFY_FAILED, Node 는 UNABLE_TO_VERIFY_LEAF_SIGNATURE 로 여전히 실패했다. REQUESTS_CA_BUNDLE·SSL_CERT_FILE·NODE_EXTRA_CA_CERTS 를 주자 모두 통과했다. pip 가 통과한 것은 이 이미지의 pip 가 우분투가 고친 판이라 운영체제 묶음을 읽기 때문이다.
그래서 현장에서는 환경 변수를 한 곳(/etc/profile.d/ 의 파일, 서비스라면 유닛의 Environment)에 모아 두고, 새 서버를 받으면 도구별로 한 번씩 접속해 보는 표를 남긴다. "OS 에 넣었으니 됐다" 는 절반만 맞다.
현장의 사설 PKI 는 루트 아래 중간 CA 를 두고 서버 인증서는 중간 CA 가 발급하는 경우가 많다. 클라이언트 저장소에는 루트만 있다는 전제에서 경로가 이어지는 순서를 옮겼다.
- 클라이언트 저장소에는 루트만 있다신뢰 저장소에 넣어 둔 것은 루트 CA 인증서이다. 루트 인증서에는 basicConstraints 의 CA:TRUE 가 있어야 다른 인증서에 서명할 자격이 된다.
- 서버가 자기 인증서와 중간 CA 인증서를 함께 보낸다잎 인증서 뒤에 중간 CA 를 이어 보내야 클라이언트가 잎에서 중간 CA 를 거쳐 루트까지 경로를 이을 수 있다.
- 중간 CA 가 빠지면 경로가 끊긴다서버에 잎 인증서만 걸면 unable to get local issuer certificate 가 난다. 본문은 이것을 클라이언트가 아니라 서버 설정의 잘못이라고 한다.
여기서 구분할 것 RFC 8446 이 뒷받침하는 것은 TLS 1.3 에서 보내는 쪽의 인증서가 목록 맨 앞에 오고 뒤따르는 인증서는 바로 앞의 것을 인증하며, 신뢰 앵커는 상대가 이미 갖고 있다고 알려진 경우 목록에서 뺄 수 있다는 규칙이다. 사설 PKI 가 루트 아래 중간 CA 를 두는 구성과 오류 문구는 본문의 서술이다.
잠깐, 예측해 보세요 서버의 체인 파일에 루트 CA 인증서까지 넣어야 클라이언트가 경로를 이을 수 있을까?
설명 확인 · 채점 없는 자가 점검
본문은 클라이언트 저장소에 루트가 이미 있으니 서버는 자기 인증서와 중간 CA 인증서를 함께 보내면 된다고 한다. RFC 8446 도 신뢰 앵커는 상대가 이미 갖고 있다고 알려진 경우 체인에서 생략할 수 있다고 적는다.
다음 실습에서 할 것
사설 루트 CA 와 SAN 이 든 서버 인증서를 만들어 repo.airgap.internal:8443 을 HTTPS 로 띄운다. 운영체제 저장소에 넣고, 파이썬과 Node 가 믿도록 /etc/profile.d/airgap-ca.sh 에 환경 변수를 모은다. 도구마다 "운영체제 저장소만으로 되는가" 를 표로 적으면 채점기가 같은 조건으로 다시 재서 대조한다. 마지막에 중간 CA 로 발급한 인증서를 체인과 함께 내보낸다.
참고 문서: OpenSSL x509v3_config · RFC 9525 · update-ca-certificates(8) · RHEL: Using shared system certificates · requests: CA Certificates · httpx SSL · Node.js CLI(NODE_EXTRA_CA_CERTS) · Go crypto/x509 · curl SSL CA certificates