메일 서버 STARTTLS 인증서 점검
이 글의 순서 13개 절
웹 인증서를 새로 받으면서 메일 서버도 같은 인증서 파일을 쓰도록 바꿨다. 브라우저로 사이트를 열어 자물쇠까지 확인했다. 다음 날 아침, Outlook을 쓰는 직원들이 이 창을 캡처해 보낸다.
The server you are connected to is using a security certificate that cannot be verified.
The target principal name is incorrect.
Do you want to continue using this server?
두 번째 문장은 Windows 오류 SEC_E_WRONG_PRINCIPAL(0x80090322)의 설명과 같은 문구다. 메일 설정에
적힌 서버 이름이 인증서에 없다는 뜻이다. 새 인증서에는 example.com과 www.example.com만 있고
직원들이 쓰는 mail.example.com은 빠졌다. 브라우저로는 이게 보이지 않는다. 웹은 443에서 웹서버가,
메일은 587이나 993 같은 다른 포트에서 메일 데몬이 각자 인증서를 내보낸다.
메일 서버 인증서는 포트마다, 데몬마다, 접속하는 쪽마다 따로 봐야 한다. 명령 출력은 2026-10-15에 OpenSSL 4.0.3, curl 8.7.1(macOS), dig 9.10.6으로 직접 실행한 결과다.
포트와 TLS 방식
implicit TLS는 TCP 연결 직후 바로 TLS 핸드셰이크를 한다. 993과 465에 평문으로 붙어 5초를 기다렸지만
인사말이 오지 않았다. 서버가 ClientHello를 기다리기 때문이다. STARTTLS는 평문으로 인사를 주고받다가,
서버가 기능 목록에 STARTTLS를 광고하면 클라이언트가 STARTTLS(POP3는 STLS)를 보내 같은 연결을
TLS로 바꾼다.
| 포트 | 서비스 | 방식 | 쓰는 쪽 | 규격 |
|---|---|---|---|---|
| 25 | SMTP 중계 | STARTTLS (기회적) | 메일 서버 → 메일 서버 | RFC 5321, RFC 3207 |
| 587 | submission | STARTTLS | 클라이언트 → 발송 서버 | RFC 6409 |
| 465 | submissions | implicit TLS | 클라이언트 → 발송 서버 | RFC 8314 |
| 143 | IMAP | STARTTLS | 클라이언트 → 메일함 | RFC 9051 |
| 993 | IMAPS | implicit TLS | 〃 | RFC 8314 |
| 110 | POP3 | STLS | 〃 | RFC 2595 |
| 995 | POP3S | implicit TLS | 〃 | RFC 8314 |
RFC 8314(2018-01, “Cleartext Considered Obsolete”)는 클라이언트와 서버 사이 구간에 TLS 1.2 이상을 쓰고, 평문 프로토콜은 단계적으로 없애고, STARTTLS보다 implicit TLS를 쓰라고 권한다. 발송은 과도기 동안 587과 465를 둘 다 구현하라고(SHOULD) 한다. 그러면서도 구현이 올바르고 양쪽이 TLS 성공을 요구하도록 설정돼 있으면 둘의 보안 차이는 크지 않다고 적는다. IMAP 규격 RFC 9051은 서버에 993 구현을 의무로 두고, 클라이언트에게는 따로 설정이 없으면 993과 143을 동시에 시도하라고 권한다(SHOULD). 그러니 두 포트가 다른 인증서를 내면 클라이언트마다 결과가 갈린다.
25번은 서버끼리 MX 레코드로 상대를 찾아 메일을 주고받는 구간이다. RFC 3207은 MX로 공개된 서버가
STARTTLS를 강제하면 안 된다(MUST NOT)고 정한다. 보내는 쪽도 대개 기회적 TLS다. Postfix의
smtp_tls_security_level = may는 상대가 지원하면 TLS를 쓰고 아니면 평문으로 보내며, 핸드셰이크가
실패하면 TLS 없이 다시 연결한다. 그래서 25번 인증서가 만료돼도 이런 쪽의 메일은 그대로 들어온다.
실패하는 건 인증서를 검증하겠다고 미리 약속한 경우(아래 MTA-STS와 DANE)뿐이다. 25번 인증서는 이렇게
조용히 깨진다.
STARTTLS 대화를 눈으로 본다
curl은 -v로 SMTP, IMAP, POP3 대화를 보여 준다. --ssl-reqd는 TLS로 올라가지 못하면 연결을 끊는다.
비슷한 --ssl은 STARTTLS가 안 되면 평문으로 계속하므로 점검에 쓰지 않는다(curl 매뉴얼).
curl -v --ssl-reqd smtp://smtp.gmail.com:587
< 220 smtp.gmail.com ESMTP ... - gsmtp
> EHLO (내 PC 이름)
< 250-smtp.gmail.com at your service, [(내 공인 IP)]
< 250-SIZE 35882577
< 250-8BITMIME
< 250-STARTTLS
...
< 250 SMTPUTF8
> STARTTLS
< 220 2.0.0 Ready to start TLS
...
* Server certificate:
* subject: CN=smtp.gmail.com
* start date: Sep 18 20:01:46 2026 GMT
* expire date: Dec 11 20:01:45 2026 GMT
* subjectAltName: host "smtp.gmail.com" matched cert's "smtp.gmail.com"
* issuer: C=US; O=Google Trust Services; CN=WE2
* SSL certificate verify ok.
> EHLO (내 PC 이름)
< 250-smtp.gmail.com at your service, [(내 공인 IP)]
< 250-SIZE 35882577
< 250-8BITMIME
< 250-AUTH LOGIN PLAIN XOAUTH2 PLAIN-CLIENTTOKEN OAUTHBEARER XOAUTH
...
괄호 안은 가렸다. TLS 전의 EHLO 응답에는 STARTTLS가 있고 AUTH가 없다. TLS 뒤에는 AUTH가 생기고
STARTTLS가 사라진다. RFC 3207의 규칙 그대로다. TLS 핸드셰이크가 끝나면 SMTP는 처음 상태로
돌아가고, 양쪽은 TLS 전에 알게 된 기능 목록을 버려야 한다(MUST). 평문 구간은 공격자가 고칠 수 있기
때문이다(§6). 서버는 TLS 뒤 EHLO 응답에 STARTTLS를 넣으면 안 된다(MUST NOT).
IMAP도 같다. outlook.office365.com:143에 평문으로 붙은 결과다(S: 서버, C: 클라이언트).
S: * OK Microsoft Exchange IMAP4 service ready. ...
C: a1 CAPABILITY
S: * CAPABILITY IMAP4 IMAP4rev1 LOGINDISABLED STARTTLS SASL-IR UIDPLUS MOVE ID UNSELECT CHILDREN IDLE NAMESPACE LITERAL+
S: a1 OK CAPABILITY completed.
C: a2 STARTTLS
S: a2 OK Begin TLS negotiation now.
LOGINDISABLED는 지금 LOGIN 명령을 받지 않는다는 광고다. RFC 9051은 클라이언트가 이 광고를 보면
LOGIN을 보내면 안 된다(MUST NOT)고 정한다. POP3는 CAPA 응답에 STLS가 있었고, STLS에
+OK Begin TLS negotiation.으로 답했다.
openssl s_client로 인증서를 받는다
openssl s_client -starttls smtp -connect smtp.gmail.com:587 -servername smtp.gmail.com </dev/null
Connecting to 64.233.189.109
...
Certificate chain
0 s:CN=smtp.gmail.com
i:C=US, O=Google Trust Services, CN=WE2
a:PKEY: EC, (prime256v1); sigalg: ecdsa-with-SHA256
v:NotBefore: Sep 18 20:01:46 2026 GMT; NotAfter: Dec 11 20:01:45 2026 GMT
1 s:C=US, O=Google Trust Services, CN=WE2
i:C=US, O=Google Trust Services LLC, CN=GTS Root R4
...
Verification: OK
---
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
...
Verify return code: 0 (ok)
Certificate chain 아래가 서버가 보낸 목록이고, v: 줄(OpenSSL 3.0부터)의 NotAfter가 만료일이다.
Verify return code: 0 (ok)는 체인을 검증한 결과일 뿐 이름은 확인하지 않았다(함정 1).
| 포트 | 옵션 |
|---|---|
| 25·587 | -starttls smtp |
| 143 | -starttls imap |
| 110 | -starttls pop3 |
| 465, 993, 995 | 없음 (implicit TLS) |
implicit TLS 포트는
openssl s_client -connect imap.gmail.com:993 -servername imap.gmail.com처럼 옵션 없이 붙는다.
openssl이 보내는 EHLO 이름은 기본값이 mail.example.com이다. EHLO 이름을 따지는 서버라면
-name <내 호스트 이름>으로 바꾼다(OpenSSL 매뉴얼).
포트를 한 번에 돈다
같은 호스트의 메일 포트가 모두 같은 인증서를 내는지 본다.
#!/bin/bash
# 사용법: ./mail-ports.sh mail.example.com [포트...]
host=$1; shift
ports=${*:-"25 465 587 993 995 143 110"}
for p in $ports; do
case $p in
25|587) opt="-starttls smtp" ;;
143) opt="-starttls imap" ;;
110) opt="-starttls pop3" ;;
*) opt="" ;;
esac
printf '%-5s ' "$p"
echo | openssl s_client $opt -connect "$host:$p" -servername "$host" 2>/dev/null \
| openssl x509 -noout -serial -enddate 2>/dev/null | tr '\n' ' '
echo
done
$ ./mail-ports.sh outlook.office365.com 993 995 143 110 587 25
993 serial=0E371D2766FD365DADDCF4004297839E notAfter=Jan 10 23:59:59 2027 GMT
995 serial=0E371D2766FD365DADDCF4004297839E notAfter=Jan 10 23:59:59 2027 GMT
143 serial=0E371D2766FD365DADDCF4004297839E notAfter=Jan 10 23:59:59 2027 GMT
110 serial=0E371D2766FD365DADDCF4004297839E notAfter=Jan 10 23:59:59 2027 GMT
587 serial=0E371D2766FD365DADDCF4004297839E notAfter=Jan 10 23:59:59 2027 GMT
25 serial=0E371D2766FD365DADDCF4004297839E notAfter=Jan 10 23:59:59 2027 GMT
한 줄만 serial이 다르면 그 포트를 맡은 데몬부터 본다. 빈 줄은 연결에 실패했거나 인증서를 받지 못한 경우다(함정 2·3).
함정 1. Verify return code: 0은 이름을 검사하지 않은 결과다
OpenSSL 매뉴얼은 s_client를 검증 오류가 나도 핸드셰이크를 계속하는 테스트 도구로 설명한다. 이름은
-verify_hostname을 줘야 본다. wrong.host.badssl.com은 이름이 맞지 않는 *.badssl.com 인증서를
내는 테스트 사이트다(와일드카드는 한 단계만 덮는다).
$ openssl s_client -connect wrong.host.badssl.com:443 -servername wrong.host.badssl.com </dev/null
...
Verify return code: 0 (ok)
$ openssl s_client -connect wrong.host.badssl.com:443 -servername wrong.host.badssl.com \
-verify_hostname wrong.host.badssl.com </dev/null
depth=0 CN=*.badssl.com
verify error:num=62:hostname mismatch
...
Verify return code: 62 (hostname mismatch)
클라이언트가 실제로 쓰는 이름을 -verify_hostname에 준다. 맞으면 Verified peername:이 나온다.
outlook.office365.com:143에서는 Verified peername: *.office365.com이었다. 스크립트라면
-verify_return_error를 더해 검증 오류에서 끊는다. OpenSSL의 이름 검사가 브라우저보다 느슨한 경우는
ERR_CERT_COMMON_NAME_INVALID 원인별 해결에 있다.
함정 2. 인증서를 못 받아도 0 (ok)가 나온다
STARTTLS를 광고하지 않는 SMTP 서버를 로컬(127.0.0.1)에 띄워 붙였다.
$ openssl s_client -starttls smtp -connect 127.0.0.1:2525 -servername mail.example.com </dev/null
Connecting to 127.0.0.1
Didn't find STARTTLS in server response, trying anyway...
...:error:0A00010B:SSL routines:tls_validate_record_header:wrong version number:...
CONNECTED(00000003)
---
no peer certificate available
...
Verify return code: 0 (ok)
openssl은 광고가 없어도 “그래도 시도”하고, 서버의 평문 오류 응답을 TLS 레코드로 읽다가 실패한다.
인증서를 받지 못했는데 마지막 줄은 0 (ok)다. Verify return code만 grep하는 감시는 이걸 정상으로
본다. Certificate chain이 있는지부터 본다.
함정 3. 포트와 방식을 섞으면 엉뚱한 에러가 난다
$ openssl s_client -connect smtp.gmail.com:587 -servername smtp.gmail.com </dev/null
...:error:0A00010B:SSL routines:tls_validate_record_header:wrong version number:...
$ openssl s_client -starttls smtp -connect smtp.gmail.com:465 -servername smtp.gmail.com </dev/null
Didn't find STARTTLS in server response, trying anyway...
...:error:0A000126:SSL routines::unexpected eof while reading:...
587에 -starttls 없이 붙으면 서버의 평문 인사(220 ...)를 TLS 레코드로 읽어 바로
wrong version number가 난다. 465에 -starttls smtp를 주면 오지 않을 인사말을 기다리다가, 이번에는
약 10초 뒤 서버가 끊었다. STARTTLS 미지원처럼 보이는 메시지다. 인증서 문제로 단정하기 전에 포트와
옵션부터 맞춘다.
함정 4. SNI를 보내지 않는 상대
s_client는 OpenSSL 1.1.1부터 접속 이름을 SNI로 보낸다. 상대 MTA는 다르다. Postfix 문서는 SMTP
클라이언트가 DANE을 쓰지 않으면 기본으로 SNI를 보내지 않는다고 적는다(smtp_tls_servername). 서버가
SNI로 인증서를 고르는 구성(Dovecot local_name, Postfix tls_server_sni_maps)이면 SNI 없는 접속에는
기본 인증서가 나간다. 로컬에 SNI로는 새 인증서를, 기본값으로는 옛 인증서를 내는 서버를 띄워
비교했다.
$ openssl s_client -connect 127.0.0.1:4465 -servername mail.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -serial -enddate
serial=6BA69BE0E113F9D39736058A2F793A81
notAfter=Jan 12 00:00:00 2027 GMT
$ openssl s_client -connect 127.0.0.1:4465 -noservername </dev/null 2>/dev/null \
| openssl x509 -noout -serial -enddate
serial=69849F7D61A82786926DB6000C6664AD
notAfter=Oct 14 00:00:00 2026 GMT
openssl로는 새 인증서가 보이는데, SNI 없이 들어오는 상대 MTA는 만료된 기본 인증서를 받는다. SNI를
쓰는 메일 서버는 -noservername으로도 한 번 보고, 인증서가 다르면 기본 인증서도 교체한다.
25번은 내 자리에서 막혀 있을 수 있다
25번 시험이 시간 초과로 끝나면 내 쪽 망부터 의심한다. 예를 들어 AWS 문서는 EC2가 기본으로 25번 출구 트래픽을 사설 IPv4 주소로만 허용한다고 적는다. 메일을 실제로 보내는 서버에서 시험하거나 아래 certdesk 같은 바깥 측정 지점을 쓴다.
장애 1. 만료 인증서가 메일 데몬에만 남는다
자동 갱신도 됐고 웹은 새 인증서를 내는데 메일 클라이언트만 만료 경고를 띄운다. 원인은 대개 이 중 하나다.
- 갱신 훅이 웹서버만 리로드한다. 메일 데몬은 전에 읽은 인증서를 계속 낸다.
- 메일 데몬이 복사본을 본다.
/etc/postfix/같은 곳에 복사한 파일은 갱신되지 않는다. - 메일용 이름이 다른 인증서다.
mail.example.com을 따로 발급했는데 그쪽 갱신이 멈췄다.
디스크의 serial(openssl x509 -in /etc/letsencrypt/live/mail.example.com/cert.pem -noout -serial -enddate)과
위 mail-ports.sh 결과를 비교한다. 다른 포트가 있으면 그 데몬이 인증서를 다시 읽게 한다. Certbot은
갱신에 성공했을 때만 도는 deploy 훅을 /etc/letsencrypt/renewal-hooks/deploy/의 실행 파일로 받는다.
#!/bin/sh
# /etc/letsencrypt/renewal-hooks/deploy/reload-mail.sh
postfix reload
doveadm reload
postfix reload는 설정 파일을 다시 읽고 실행 중인 프로세스를 적당한 때 끝낸다(postfix(1)).
doveadm reload는 Dovecot이 설정을 다시 읽게 한다(doveadm(1)). Dovecot 2.3의 ssl_cert = </파일에서
<는 파일 이름이 아니라 파일 내용을 설정값으로 읽으라는 표시다. 웹서버 쪽은
Nginx 인증서 교체와 무중단 리로드에 있다.
장애 2. 중간 인증서 누락
Postfix 문서는 인증서 파일에 서버 인증서를 먼저, 그 뒤에 발급 CA 인증서를 넣으라고 한다. Dovecot
문서도 같은 순서를 말하고 CA가 주는 ‘full chain’ 파일을 권한다. Certbot이면 fullchain.pem이다.
cert.pem은 서버 인증서 하나만 든 파일이라서, Certbot 문서는 이걸 쓰려면 chain.pem도 함께 주라고
한다.
# Postfix ≥ 3.4 — 키 다음에 그 인증서 체인
smtpd_tls_chain_files =
/etc/letsencrypt/live/mail.example.com/privkey.pem,
/etc/letsencrypt/live/mail.example.com/fullchain.pem
# Dovecot 2.4
ssl_server_cert_file = /etc/letsencrypt/live/mail.example.com/fullchain.pem
ssl_server_key_file = /etc/letsencrypt/live/mail.example.com/privkey.pem
# Dovecot 2.3
ssl_cert = </etc/letsencrypt/live/mail.example.com/fullchain.pem
ssl_key = </etc/letsencrypt/live/mail.example.com/privkey.pem
누락은 출력에서 바로 보인다. 중간 인증서를 보내지 않는 테스트 사이트에 붙였다.
$ openssl s_client -connect incomplete-chain.badssl.com:443 -servername incomplete-chain.badssl.com </dev/null
depth=0 CN=*.badssl.com
verify error:num=20:unable to get local issuer certificate
...
0 s:CN=*.badssl.com
i:C=US, O=Let's Encrypt, CN=YR1
...
Verify return code: 21 (unable to verify the first certificate)
Certificate chain에 0번 하나뿐이고 오류는 20과 21이다. 중간 인증서를 스스로 받아 오지 않는
클라이언트와 상대 MTA는 여기서 실패한다. 클라이언트마다 결과가 갈리는 이유는
크롬은 되는데 파이어폭스만 인증서 오류에 있다.
장애 3. 이름 불일치
누가 어떤 이름으로 검사하는지부터 다르다.
| 상대 | 검사하는 이름 | 근거 |
|---|---|---|
| 메일 클라이언트 | 메일 주소의 도메인, 또는 연결에 쓴 호스트 이름(CNAME을 풀지 않은 이름) | RFC 7817 §3 |
| MTA-STS를 따르는 MTA | MX 호스트 이름. 정책 mx 패턴과도, 인증서 SAN과도 맞아야 한다 |
RFC 8461 §4 |
| DANE-EE(3)로 검증하는 MTA | 이름을 보지 않는다 (TLSA가 키를 고정) | RFC 7672 §3.2.1 |
| 기회적 TLS만 쓰는 MTA | 검증을 요구하지 않는다 | Postfix may |
mail.example.com이 업체의 mx1.hosting.example을 CNAME으로 가리켜도 클라이언트는 CNAME을 풀기 전
이름으로 검사한다. 업체 인증서에 그 이름이 없으면 실패다.
MX 이름과 클라이언트에 안내한 이름이 다르면 둘 다 SAN에 넣는다. MX는 mx.example.com, 안내는
mail.example.com인 식이다. 와일드카드라면 RFC 7817은 *.example.com이 example.com에 맞지
않는다고 적는다. apex로 안내했다면 apex를 따로 넣는다.
Gmail은 MX 다섯 개(gmail-smtp-in.l.google.com, alt1~alt4)를 25번 인증서에 모두 넣었다. CN은
다른 이름이다. 이름 검사는 SAN을 본다.
$ openssl s_client -starttls smtp -connect gmail-smtp-in.l.google.com:25 \
-servername gmail-smtp-in.l.google.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -ext subjectAltName
subject=CN=mx.google.com
X509v3 Subject Alternative Name:
DNS:mx.google.com, DNS:smtp.google.com, DNS:aspmx.l.google.com, ... DNS:gmail-smtp-in.l.google.com, DNS:alt1.gmail-smtp-in.l.google.com, ...
MX 이름(dig +short MX <도메인>)과 클라이언트 안내 이름마다 -verify_hostname을 한 번씩 돌린다.
장애 4. STARTTLS 미지원과 다운그레이드
EHLO에 STARTTLS가 없으면 openssl은 “Didn’t find STARTTLS”를 내고, curl은 이렇게 끝난다(로컬
시험 서버).
$ curl -sS --ssl-reqd smtp://127.0.0.1:2525
curl: (64) STARTTLS not supported.
Postfix 소스(smtpd.c)를 보면 TLS를 꺼 둔 서버는 STARTTLS 명령에
502 5.5.1 Error: command not implemented로 답한다. TLS를 켰지만 초기화에 실패한 서버(인증서를 못
읽는 경우 등)는 454 4.7.0 TLS not available due to local problem으로 답한다. 454는 RFC 3207의
“일시적 이유로 TLS 불가”다. 인증서 경로를 바꾼 뒤 이게 보이면 파일 경로와 권한부터 본다.
기회적 TLS로 보내는 MTA는 이때 평문으로 보낸다. 메일은 계속 오가니 아무도 모른다. 메일 클라이언트 쪽은 RFC 8314가 더 엄격하다. 최소 보안 수준을 요구하는 클라이언트는 그 수준을 못 맞춘 연결에서 기능 확인과 STARTTLS 말고는 아무것도 하면 안 되고(MUST NOT), 클릭 한 번으로 그냥 진행하게 해서도 안 된다(SHOULD NOT).
다운그레이드도 있다. RFC 3207 §6은 중간자가 250 STARTTLS 한 줄을 지워 TLS 시도를 막는 공격을 적어
두었다. RFC 7672는 25번 SMTP에 대해 “어떤 TLS 보안 기능도 이를 막지 못한다”고 쓴다. 구현 실수도
있었다. STARTTLS 명령과 같은 TCP 버퍼에 끼워 보낸 평문 명령을 TLS 뒤의 명령처럼 처리하는 STARTTLS
명령 주입 취약점(CERT VU#555316)이다. RFC 8314는 이를 implicit TLS를 권하는 이유로 든다.
그래서 클라이언트와 서버 사이는 465, 993, 995의 implicit TLS를 쓰고 클라이언트에서 TLS를 필수로 둔다. 서버끼리 주고받는 25번은 STARTTLS를 끌 수 없으니 “TLS와 유효한 인증서를 쓴다”를 미리 알린다. 그 방법이 MTA-STS와 DANE이다.
MTA-STS (RFC 8461)
MTA-STS(2018-09)는 받는 도메인이 “MX는 STARTTLS와 공인 CA 인증서(PKIX)를 쓴다”고 미리 약속해 두는 방식이다. 게시는 두 군데에 한다.
_mta-sts.<도메인>TXT:v=STSv1; id=.... id가 바뀌면 보내는 쪽이 정책을 다시 받는다.https://mta-sts.<도메인>/.well-known/mta-sts.txt:version,mode(enforce, testing, none),mx(허용 MX 패턴),max_age(캐시 초, 최대 31557600)
$ dig +short TXT _mta-sts.gmail.com
"v=STSv1; id=20190429T010101;"
$ curl -sS https://mta-sts.gmail.com/.well-known/mta-sts.txt
version: STSv1
mode: enforce
mx: smtp.google.com
mx: gmail-smtp-in.l.google.com
mx: *.gmail-smtp-in.l.google.com
max_age: 86400
보내는 MTA는 MX 이름이 정책의 mx 패턴과 맞는지, 그 MX가 STARTTLS를 지원하고 그 이름에 유효한
인증서를 내는지 본다(§4). 인증서는 만료되지 않았고, 신뢰하는 루트로 이어지고, SAN이 호스트 이름과
맞아야 한다. enforce면 실패한 MX로 보내면 안 된다(MUST NOT). 일시 오류로 다뤄 다시 시도하고, 영구
실패로 처리하기 전에 새 정책이 있는지 DNS로 확인한다. testing이면 보내고 보고만 한다.
MX 인증서는 공인 CA가 발급한 것이어야 한다. 자체 서명 MX로 enforce를 켜면 MTA-STS를 따르는 쪽의 메일이 들어오지 않는다.
정책 호스트 mta-sts.<도메인>의 HTTPS 인증서도 있다. §3.3은 이 인증서가 유효하고 만료되지 않아야
한다고 정한다. 정책을 받지 못하면 보내는 쪽은 캐시가 살아 있는 동안 캐시를 쓰고, 캐시가 없으면
MTA-STS가 없는 도메인처럼 보낸다. 그래서 이 인증서가 만료되면 보호가 소리 없이 꺼진다. 메일
인증서와 같은 목록에 넣어 관리한다.
백업 MX도 본다. 보내는 쪽은 우선순위대로 시도하므로 백업 MX의 설정 오류는 주 MX가 죽었을 때야 드러날 수 있다(§8.4). 정책을 바꿀 때는 HTTPS 본문을 먼저, TXT의 id를 나중에 바꾼다(§8.1).
Google 문서는 Gmail이 enforce 정책을 게시한 외부 서버로 보낼 때 MTA-STS를 따른다고 적고, Microsoft
문서는 Exchange Online의 발신 MTA-STS 검증이 항상 켜져 있다고 적는다. 둘 다 testing으로 시작해
보고서를 확인한 뒤 enforce로 바꾸라고 권한다. 보고서는 TLS-RPT(RFC 8460)로 받고, 받을 곳은
_smtp._tls.<도메인> TXT에 적는다(Gmail: "v=TLSRPTv1;rua=mailto:sts-reports@google.com"). 장애
중에 급히 풀 때는 Microsoft 문서대로 mode를 testing으로 바꿔 올리고 TXT의 id를 새 값으로 바꾼다.
DANE (RFC 7672)
DANE(2015-10)은 MX의 인증서(또는 공개키) 지문을 DNSSEC로 서명한 TLSA 레코드
_25._tcp.<MX 호스트>에 게시해 키를 고정한다. 보내는 쪽이 검증된(“secure”) TLSA를 찾으면 TLS가
필수가 되고, 인증서가 맞지 않으면 다음 MX로 가거나 보류한다(§2.2). 권장값 3 1 1(DANE-EE, 공개키,
SHA-256)로 검증하면 인증서의 이름을 보지 않고(§3.2.1) 만료일도 무시한다(§3.1.1). 유효성은 TLSA의
DNSSEC 서명이 정한다.
$ dig +short MX ietf.org
0 mx.ietf.org.
$ dig @1.1.1.1 +dnssec TLSA _25._tcp.mx.ietf.org
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1
_25._tcp.mx.ietf.org. 300 IN TLSA 3 1 1 292481EEE04F9F5CE0DC1728488DEE2510053AF6D62F4E7CDEF8C82D 811A5AE9
_25._tcp.mx.ietf.org. 300 IN TLSA 3 1 1 E3008A5C0BC1E088455EC997DA389D31D2B379F76727BCB6594316F6 058E6190
...
ad 플래그는 검증된 응답이라는 뜻이다. 3 1 1 값은 서버 공개키(SPKI)의 SHA-256이다. 직접 계산하면
두 번째 레코드와 같다.
$ openssl s_client -starttls smtp -connect mx.ietf.org:25 -servername mx.ietf.org </dev/null 2>/dev/null \
| openssl x509 -noout -pubkey | openssl pkey -pubin -outform DER | openssl dgst -sha256
SHA2-256(stdin)= e3008a5c0bc1e088455ec997da389d31d2b379f76727bcb6594316f6058e6190
s_client에 -dane_tlsa_domain mx.ietf.org -dane_tlsa_rrdata "3 1 1 E300..."를 주면 DANE 검증까지
하고 DANE TLSA 3 1 1 ...6727bcb6594316f6058e6190 matched the EE certificate at depth 0을 찍는다.
갱신 때 키가 바뀌면
Certbot은 기본으로 갱신할 때마다 새 개인키를 만든다(“Not reusing private keys is the default
behavior of Certbot”). 새 키의 인증서를 배포하는 순간 3 1 1과 맞지 않게 되고, DANE을 지원하는
보내는 쪽은 메일을 보류한다. Exchange Online이라면
4/5.7.323 tlsa-invalid: The domain failed DANE validation.이다(Microsoft 문서).
키를 유지하려면 --reuse-key를 쓴다. Postfix 문서도 3 1 1은 같은 키로 갱신하면 그대로 동작한다고
적는다. 키를 바꿔야 하면 TLSA를 먼저 손댄다. 새 키 레코드를 옛 레코드와 함께 게시하고, TTL의 두 배
이상 지난 뒤 새 인증서를 배포하고, 그다음에 옛 레코드를 지운다(RFC 7671 §8.1).
DANE-TA(2 x x)를 쓰면 그 CA 인증서를 체인에 넣어 보내야 한다. 루트라도 그렇다(RFC 7672 §3.1.2).
보내는 쪽에서는 Postfix가 smtp_tls_security_level = dane(DNSSEC 검증 리졸버 필요)일 때 TLSA를
쓴다. Microsoft 문서는 Exchange Online의 발신 DANE 검증이 기본으로 켜져 있다고 적는다.
클라이언트와 상대 서버에서 보이는 모습
| 어디서 | 보이는 것 | 뜻 |
|---|---|---|
| Outlook (Windows) | The server you are connected to is using a security certificate that cannot be verified. + 원인 문장 |
The target principal name is incorrect.는 이름 불일치 |
| Thunderbird | The certificate for {호스트} expired on {날짜}. |
만료 |
| Thunderbird | The certificate for {호스트} is not valid for that server. ... |
이름 불일치 |
| Thunderbird | The certificate for {호스트} does not come from a trusted source. |
신뢰할 수 없는 발급자·체인 |
| Exchange Online 발신자의 반송 | 5.4.8 MX hosts of '{domain}' failed MTA-STS validation |
MX가 정책 mx와 다름 |
| 〃 | 5.7.5 Remote certificate failed MTA-STS validation. Reason: ... |
MX 인증서가 MTA-STS 검증 실패 |
| 〃 | 4/5.7.321 starttls-not-supported, 4/5.7.322 certificate-expired, 4/5.7.323 tlsa-invalid |
DANE 도메인의 STARTTLS, 만료, TLSA 실패 |
Postfix 발신 로그 (smtp_tls_loglevel = 1) |
Untrusted, Trusted, Verified, Anonymous TLS connection established to ... |
첫 단어가 상대 인증서의 검증 수준 |
Outlook 문구는 Microsoft의 Outlook 2007 안내 글과 2025년 Microsoft Q&A의 Microsoft 365 사례에 같은 형태로 나온다. “예”를 누르면 계속 쓸 수 있어서 방치되기 쉽다. RFC 8314는 메일 클라이언트가 인증서 고정(pinning)을 계정 설정 때만 제안하고, 기존 계정의 검증 실패에 대한 대응으로는 제안하면 안 된다(MUST NOT)고 정한다. 경고 창의 “예”는 해결이 아니다.
Thunderbird 문구는 소스(comm-central certError.ftl)의 현재 값이다. 호스트 자리에 포트가 붙기도
한다. Postfix 로그에서 Verified는 이름까지 확인된 경우다. DANE 인증에 성공해도 MX 조회가 DNSSEC로
보호되지 않았으면 Trusted로만 남긴다(postconf(5)).
certdesk로 보기
certdesk 진단은 host:포트를 받는다. /check/?domain=smtp.example.com:587 같은 주소로 열면
바로 실행된다(예: smtp.gmail.com:587 진단). 아래는 소스에 있는
동작만 적었다.
implicit TLS로는 465, 993, 995 등 정해진 TLS 포트를, STARTTLS로는 25, 587(SMTP), 143(IMAP),
110(POP3), 389(LDAP)를 잰다. STARTTLS 포트는 평문 대화로 TLS 전환을 마친 뒤 측정하고, 결과의 포트
항목에 전환 방식(587이면 SMTP STARTTLS)을 같이 표시한다. 광고가 없거나 명령이 거절되면(454 포함)
이 포트의 통신이 암호화되지 않는다고 보고 위험으로 판정한다. 대화 자체가 어긋나면 STARTTLS 전환
실패로 측정 불가가 된다.
판정은 443과 같다. 입력한 이름으로 이름을 검사하고, 빠진 중간 인증서 이름을 알려 주고, 만료와 CRL 폐기를 본다. 주소(A/AAAA)가 여럿이면 다른 주소의 인증서와 비교한다.
한계도 있다. 443 외 포트는 공용 측정망 비교 없이 한국 측정 지점 A에서만 잰다. MX를 스스로 찾지 않으니 MX 이름을 넣어야 한다(도메인 진단의 DNS 항목 ’메일(MX)’에 나온다). MTA-STS 정책과 TLSA는 보지 않는다.
같은 결과를 주는 API(/api/v1/certificate)로 smtp.gmail.com:587을 조회했다(일부 필드 생략,
headline의 날짜는 KST).
{
"target": "smtp.gmail.com:587",
"status": "OK",
"headline": "인증서 정상. 2026-12-12 만료 (57일 남음)",
"served": {
"issuer": { "CN": "WR2", "O": "Google Trust Services" },
"notAfter": "2026-12-11T20:01:39Z",
"port": 587,
"starttls": "smtp",
"chainLength": 3
},
"tls": { "skipped": true, "reason": "443 외 포트는 공용 측정망으로 측정하지 않음" }
}
발급자가 위 openssl 결과(WE2)와 다르다. 측정 지점이 붙은 192.178.226.109는 WR2가 발급한
인증서를 낸다. 같은 날 주소 다섯 곳을 비교하니 WE2와 WR2가 발급한 인증서가 섞여 있었고, 이름과
만료일은 같았다. serial이 달라도 이름, 만료일, 검증 결과가 맞으면 정상일 수 있다. 주소마다 따로
보는 법은 NET::ERR_CERT_DATE_INVALID 원인과 해결의 ‘일부 노드만
갱신’ 절에 있다.
점검 순서
메일 쪽 인증서 문제는 평문으로 흘러 조용히 넘어가거나, 보내는 쪽 큐에 쌓여 늦게 드러난다. 갱신 직후 포트별로 한 번씩 받아 보는 게 가장 싸다.
1. 이름 목록 — dig MX, 클라이언트 안내 이름(smtp·imap·pop), mta-sts.<도메인>
2. 포트별 인증서 — 25·465·587·993·995 (+143·110), 디스크 serial 과 비교
└ 다른 포트 → 그 데몬 리로드, 갱신 훅에 추가 (장애 1)
3. 인증서를 받았나 — Certificate chain 이 비었으면 포트·방식부터 (함정 2·3)
4. 이름 — 이름마다 -verify_hostname (장애 3)
5. 체인 — 0번뿐이면 fullchain (장애 2)
6. SNI — -noservername 이 다른 인증서면 기본 인증서 교체 (함정 4)
7. STARTTLS — EHLO 광고, curl --ssl-reqd (장애 4)
8. MTA-STS — TXT·정책·mx 패턴, mta-sts 호스트 인증서 만료일
9. DANE — TLSA 와 공개키 해시, 키 교체 전 TLSA 선게시
감시를 직접 만든다면 인증서 만료 알림 체계 직접 만들기를 보면 된다.
참조
- RFC 3207: SMTP Service Extension for Secure SMTP over TLS
- RFC 8314: Cleartext Considered Obsolete: Use of TLS for Email Submission and Access
- RFC 7817: Updated TLS Server Identity Check Procedure for Email-Related Protocols
- RFC 6409: Message Submission for Mail
- RFC 9051: IMAP Version 4rev2
- RFC 2595: Using TLS with IMAP, POP3 and ACAP
- RFC 8461: SMTP MTA Strict Transport Security (MTA-STS)
- RFC 8460: SMTP TLS Reporting
- RFC 7672: SMTP Security via Opportunistic DANE TLS
- RFC 7671: The DANE Protocol: Updates and Operational Guidance
- OpenSSL: openssl-s_client 문서 원본
- curl: man page (–ssl-reqd)
- Postfix: TLS Support
- Postfix: postconf(5)
- Postfix: postfix(1)
- Postfix 소스(미러): smtpd.c
- Postfix 소스(미러): tls_misc.c
- Dovecot 2.4: SSL/TLS Configuration
- Dovecot 2.3: SSL configuration
- Debian manpages: doveadm(1)
- Certbot: User Guide
- Google Workspace: About MTA-STS and TLS reporting
- Microsoft Learn: Enhance mail flow with MTA-STS
- Microsoft Learn: How SMTP DANE works
- Microsoft Learn: COM Error Codes (SEC_E_WRONG_PRINCIPAL)
- Microsoft Learn (보관 글): “Target Principal Name is Incorrect” in Outlook 2007
- Microsoft Q&A: Outlook 인증서 경고 사례 (2025)
- Thunderbird 소스: certError.ftl
- AWS: Amazon EC2 service quotas (port 25)