TLS 연결 디버깅: 인증서 오류를 찾아서

몇 주 전이 아니라 거의 석달 아니면 넉달 전 일인데, TCP 기반 서비스의 SSL/TLS 를 사용한 연결이 제대로 동작하지 않는 문제를 확인할 일이 생겼다. 문제 자체는 굉장히 단순했다. 나는 TLS 연결 문제를 확인할 때 주로 OpenSSL 의 opeenssl s_client 명령을 이용하는데, 첫 문제는 이 명령어 한 번으로 찾을 수 있었다.

잘못된 TLS 설정을 사용하는 nginx 서버에 openssl s_client -connect 10.0.12.34:443 -servername hub.prgmr.net 명령을 실행하면 아래와 같은 로그를 출력한다. 테스트를 위해서 실제 서버가 아니라 10.0.12.34 에 떠 있는 서버가 실제로 해당 도메인 주소를 가지고 있다고 믿게하고, 이 서버와 연결해서 테스트했다.

CONNECTED(00000003)
depth=0 CN = hub.prgmr.net
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=0 CN = hub.prgmr.net
verify error:num=21:unable to verify the first certificate
verify return:1
---
Certificate chain
 0 s:/CN=hub.prgmr.net
   i:/C=US/O=Let's Encrypt/CN=Let's Encrypt Authority X3
---
Server certificate
-----BEGIN CERTIFICATE-----
MIIGUDCCBTigAwIBAgISA0kdStyJNTUMMLjMLXKmzZvOMA0GCSqGSIb3DQEBCwUA
... (중략)
IhVTEXbM5GE5ECs2VflW6w+zBY3wHOlMnn3lA+Dn83iWHg+J
-----END CERTIFICATE-----
subject=/CN=hub.prgmr.net
issuer=/C=US/O=Let's Encrypt/CN=Let's Encrypt Authority X3
---
No client certificate CA names sent
Server Temp Key: ECDH, P-256, 256 bits
---
SSL handshake has read 2562 bytes and written 348 bytes
---
New, TLSv1/SSLv3, Cipher is ECDHE-RSA-AES256-GCM-SHA384
Server public key is 4096 bit
Secure Renegotiation IS supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
SSL-Session:
    Protocol  : TLSv1.2
    Cipher    : ECDHE-RSA-AES256-GCM-SHA384
    Session-ID: 02085AA959197358B866CA34CF177224FD47454B18A2C49D59940B03E251472E
    Session-ID-ctx:
    Master-Key: 58465AA5FB96B22EA782C60B1A2E591D4C1F430EE0FCAF0B942E0374937C9FAA9ED8543925DFC6AFFA69A43B8142A857
    TLS session ticket lifetime hint: 300 (seconds)
    TLS session ticket:
    0000 - c5 3c 2a bb f3 20 53 e0-96 2a 13 6d 5b c8 13 54   .<*.. S..*.m[..T
    0010 - 70 ad a4 ed e3 5a e1 c6-d9 1a 54 70 e5 c6 2c ce   p....Z....Tp..,.
    0020 - 1c c1 39 10 aa 6d 79 07-6a b0 d1 dc 8f f7 a8 b7   ..9..my.j.......
    0030 - 58 cc 3a 38 ab 31 75 01-f6 c0 21 14 f0 29 b4 44   X.:8.1u...!..).D
    0040 - cc 87 d2 43 f3 63 73 63-7f 12 81 fb d4 d1 03 8b   ...C.csc........
    0050 - 6f 8d f7 1e c0 e6 bb ce-1c 78 e3 bd b9 e7 87 42   o........x.....B
    0060 - c2 7a 0e 57 00 a6 8b c3-bc 9b 6a f8 68 ff 6a 93   .z.W......j.h.j.
    0070 - c4 25 6a 27 8b b6 69 b8-85 8e 9d f0 16 d3 de 90   .%j'..i.........
    0080 - d3 7e 26 4b 90 bd a8 74-ab 38 c5 d3 8d 32 1f 28   .~&K...t.8...2.(
    0090 - 76 fd 74 d7 ea 18 0b ae-67 33 7f c0 d1 59 89 a2   v.t.....g3...Y..
    00a0 - b3 23 4c f2 12 c0 4f 40-ac fa 13 80 21 de 64 81   .#L...O@....!.d.
    00b0 - a1 8d 6d 4e f1 ad cf b4-58 3b 7e 26 92 23 42 81   ..mN....X;~&.#B.

    Start Time: 1546124091
    Timeout   : 7200 (sec)
    Verify return code: 21 (unable to verify the first certificate)

여기서 문제는 “unable to verify the first certificate” 이다. SSL/TLS는 인증서를 인증하기 위한 인증 기관 (Certificate Authority; CA) 인증서부터, 실제로 서버가 보내는 인증서까지 일련의 서명이 필요하다.

  • 각 브라우저 혹은 OS 혹은 사용 중인 TLS 라이브러리가 인식하는 루트 인증서로 서명한 인증서 (intermediate certificate)
  • 위 인증서로 서명한 또 다른 인증서 (또다른 intermediate certificate) …를 여러 단계 혹은 0 단계.
  • …마지막 중간 단계 인증서로 서명한 서버의 인증서

예를 들어 이 블로그를 구동하는 서버의 인증서는 DST Root CA X3 가 루트 인증서고, 그 인증서로 서명한 Let’s Encrypt 의 인증서, 마지막으로 해당 인증서로 서명한 이 블로그 도메인의 인증서로 이어진다.

TLS 인증서의 체인 예시

이런 형태로 연결된 일련의 인증서 (certificate chain) 가 필요하다. 그래서 “서버 인증서” 와 “서버 인증서까지 이르는 중간 단계 인증서” 를 모두 보내야하는데, 위에선 그렇지 않아서 이런 오류를 출력한다. TLS를 처음 설정할 때 이런 실수를 많이 하는데 (정식으로 서명한 인증서가 있으니 잘 돌아야하는데 왜 에러인가 같은 질문을 스택오버플로우에서 많이 찾아볼 수 있다), 잘못된 nginx 설정은 아래와 같다. 인증서 디렉터리는 Ubuntu 16.04 에서 Let’s Encrypt 를 사용하는 경우처럼 설정했다.

server {
  listen 443 ssl;
  server_name hub.prgmr.net;
  root /var/www/html;

  ssl on;
  ssl_certificate /etc/nginx/hub.prgmr.net/cert.pem;
  ssl_certificate_key /etc/nginx/hub.prgmr.net/privkey.pem;

  ssl_protocols TLSv1.2;
  ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECD
HE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA
384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256;
  ssl_prefer_server_ciphers on;
}

여기서, ssl_certificate 를 서버 인증서 파일인 으로 지정했는데, 실제로는 “서버 인증서\n중간 단계인증서k\n중간단계인증서k-1 … \n중간단계인증서0” 처럼 중간단계 인증서를 모두 포함한 파일로 지정해야 한다. Let’s Encrypt 인증서를 쓴다면 이미 해당 파일을 fullchain.pem 이란 이름으로 제공한다. 이 파일을 cert.pem 대신 지정해주면 정상적으로 TLS 연결을 맺는다.

이 설정 문제는 문제를 덮어놓은 다른 문제였고, 실제 문제는 다른 글로 정리하겠다.