LabHub
배우기 러닝패스 코스

인증서는 갱신됐는데 브라우저는 옛것을 보여줬다 · 인증서 한 장을 네 조각으로 읽는다 · 이론

인증서 오류는 넷 중 하나다

LabHub 에서 이어서 보기

한 줄 요약

인증서는 "이 공개키는 이 이름의 것이다" 를 누군가가 서명한 문서다. 검증은 서명 사슬 · 유효기간 · 이름(SAN) · 키 일치 네 가지를 각각 보는 일이고, 장애 메시지는 그중 어느 것이 깨졌는지를 정확히 가리킨다.

왜 이게 필요했나

"인증서 오류" 라는 신고는 원인이 넷 중 하나인데 화면의 문구는 대개 하나로 뭉뚱그려져 있다. 만료됐거나, 이름이 다르거나, 중간 CA 가 빠져 사슬이 끊겼거나, 배포하면서 인증서와 다른 개인키를 짝지었거나. 각각의 고치는 법이 전혀 다르기 때문에 "재발급하면 되겠지" 로 시작하면 두 번 일한다. 구조를 알면 openssl x509 -text 한 번으로 어느 칸이 문제인지 읽을 수 있다.

어떻게 동작하나

형식은 [RFC 5280](https://datatracker.ietf.org/doc/html/rfc5280) 의 X.509 v3 다. 본문(TBSCertificate)에 serialNumber(발급자 안에서 유일한 번호), issuer(서명한 CA 의 이름), validity(notBefore·notAfter), subject, subjectPublicKeyInfo(공개키), 그리고 확장(extensions)이 들어가고, 그 전체를 발급자의 개인키로 서명한 값이 붙는다. 갱신하면 같은 키를 써도 serial 과 validity 가 달라지므로 serial 이 인증서의 판(版) 번호 노릇을 한다. 어느 인증서가 나가고 있는지 확인할 때 serial 이나 SHA-256 지문을 보는 이유다.

확장 가운데 두 개가 운영에서 결정적이다. subjectAltName(SAN) 은 이 인증서가 대표하는 이름들의 목록이다. [RFC 6125](https://datatracker.ietf.org/doc/html/rfc6125) 는 이름 검사를 subject 의 CN 이 아니라 SAN 의 dNSName 으로 하라고 정하고, 현대 클라이언트는 실제로 SAN 만 본다 — CN 에 이름을 넣어 봐야 소용없다. basicConstraintscA 가 TRUE 인 인증서만 다른 인증서에 서명할 수 있고, pathLenConstraint 는 그 아래로 CA 를 몇 단계까지 둘 수 있는지 제한한다.

사슬은 리프(서버 인증서) → 중간 CA → 루트 순서다. 클라이언트는 루트만 신뢰 저장소에 갖고 있고, 서버는 리프와 중간 CA 를 함께 보내야 한다. 중간 CA 를 빠뜨리면 클라이언트는 리프의 발급자를 찾지 못한다. OpenSSL 의 verify 는 [-CAfile](https://docs.openssl.org/3.0/man1/openssl-verify/) 로 신뢰 앵커를, -untrusted 로 "믿지는 않지만 사슬을 잇는 데 쓸" 중간 인증서를 받는다.

$ openssl verify -CAfile ca.crt api.crterror 20 at 0 depth lookup: unable to get local issuer certificate     ← 중간 CA 가 없다$ openssl verify -CAfile ca.crt -untrusted inter.crt api.crtapi.crt: OK

만들 때의 함정 하나. CSR 에 -addext "subjectAltName=DNS:..." 로 SAN 을 넣어도, [openssl x509 -req](https://docs.openssl.org/3.0/man1/openssl-x509/) 로 서명하면 기본 동작은 CSR 의 확장을 버리는 것이다. 3.0 에서는 -copy_extensions copy 를 줘야 SAN 이 인증서로 옮겨진다. 이걸 빠뜨리면 발급은 멀쩡히 되는데 브라우저는 이름 불일치를 낸다 — CN 은 있으니 사람 눈에는 정상으로 보인다.

키 일치는 인증서의 공개키와 개인키에서 뽑은 공개키를 비교하면 된다. 둘 다 -pubkey/-pubout 으로 같은 PEM 형식이 나오므로 해시를 비교하는 것으로 충분하다. 만료는 -enddate 로 보고, 남은 기간의 판정은 -checkend <초> 가 한다 — 그 초 안에 만료되면 0 이 아닌 코드로 끝나므로 스크립트에 넣기 좋다.

$ openssl x509 -in server.crt -noout -checkend 604800 ; echo $?      # 7일 안에 만료되는가Certificate will expire1

현장에서 만나는 모습

"어제까지 됐는데 오늘 안 된다" 는 만료다. 인증서는 시계로 죽으니 배포와 무관하게 터진다. 이때 서버 시계가 아니라 클라이언트 시계로 판정된다는 점을 기억해야 한다 — 시계가 틀린 기계 한 대만 안 되는 사고가 실제로 있다. "curl 은 되는데 자바 앱만 안 된다" 는 사슬이다. curl 은 시스템 저장소에 중간 CA 가 캐시돼 있거나 AIA 를 따라가 잇기도 하지만, 자바나 Go 클라이언트는 서버가 보낸 것만으로 사슬을 만든다. 서버에 fullchain 을 물리면 끝난다. "리버스 프록시를 바꾼 뒤부터" 는 키 불일치를 의심한다. 인증서와 키를 다른 경로에서 가져와 짝이 어긋난 경우인데, nginx 는 기동 때 거절하지만 어떤 서버는 핸드셰이크에서야 실패한다.

다음 실습에서 할 것

루트 CA 를 만들고, SAN 두 개짜리 CSR 을 만들어 3일짜리 서버 인증서를 발급한다. -copy_extensions 를 빠뜨리면 왜 채점기가 SAN 없음으로 잡는지 직접 본다. serial·지문·만료일을 읽고, 키 일치를 해시로 판정하고, -checkend 로 7일 창의 갱신 필요 여부를 판정한다. 마지막으로 중간 CA 를 세워 리프를 발급하고 루트만으로는 실패하고 -untrusted 로는 통과하는 사슬을 만들어 fullchain.pem 을 조립한 뒤, -verify_hostname 으로 이름 검사를 표로 남긴다.