Por que funciona no navegador e falha em todo o resto
Esta é a pergunta que traz quase todo mundo aqui, e a resposta é uma diferença de comportamento que quase ninguém conhece.
Um certificado não é validado sozinho. Ele forma uma cadeia: o seu certificado foi assinado por uma autoridade intermediária, que foi assinada por uma raiz. O cliente já confia na raiz — ela vem instalada no sistema — mas precisa dos elos do meio para chegar até ela.
Quando o servidor esquece de enviar a intermediária, a cadeia fica com um buraco. E aí:
O navegador conserta sozinho. Chrome, Firefox e Safari leem um campo do certificado chamado AIA, que diz onde baixar a intermediária faltante, e vão buscar. Você nunca vê o problema.
O resto do mundo não faz isso. curl, Node, Java, Python, Go, Docker — nenhum busca a intermediária. Eles recebem a cadeia incompleta, não conseguem chegar até a raiz confiável, e recusam.
O erro de instalação que causa tudo isso
A raiz do problema costuma ser um arquivo trocado. Toda autoridade entrega pelo menos dois arquivos, e os nomes enganam:
| Arquivo | O que tem dentro | É o que o servidor precisa? |
|---|---|---|
cert.pem |
só o seu certificado | não — falta a intermediária |
chain.pem |
só as intermediárias | não — falta o seu |
fullchain.pem |
o seu + as intermediárias | sim, é este |
No nginx é a diferença entre funcionar em todo lugar e funcionar só no navegador:
ssl_certificate /etc/letsencrypt/live/dominio/fullchain.pem; # ← não cert.pem
ssl_certificate_key /etc/letsencrypt/live/dominio/privkey.pem;
O Apache separa em duas diretivas, e a intermediária tem linha própria:
SSLCertificateFile /caminho/cert.pem
SSLCertificateChainFile /caminho/chain.pem
A ordem importa
A cadeia é lida de baixo para cima e precisa vir na ordem: primeiro o seu certificado, depois quem o assinou, depois quem assinou aquele. Inverter quebra em vários clientes.
A raiz não precisa ser enviada — o cliente já tem. Mandar não costuma quebrar nada, só desperdiça alguns kilobytes em cada handshake.
O que nunca fazer
Toda pesquisa sobre erro de certificado devolve uma dessas linhas como primeira resposta. Todas elas desligam a verificação:
curl -k # ignora o certificado
NODE_TLS_REJECT_UNAUTHORIZED=0 # desliga a verificação no Node inteiro
rejectUnauthorized: false # idem, por requisição
verify=False # requests, Python
TLS faz duas coisas: criptografa e autentica. Desligar a verificação mantém a primeira e joga a segunda fora. A conexão continua embaralhada, mas você deixa de ter qualquer garantia de estar falando com quem pensa que está — que é justamente o ataque que o certificado impede.
Serve para depurar por dois minutos. Em produção, é uma porta aberta que ninguém lembra de fechar.
Quando o problema é a máquina, não o servidor
Se o mesmo endereço funciona na sua máquina e falha só em um container, o
certificado está bem — falta a lista de raízes confiáveis lá dentro. Imagem
enxuta como alpine não vem com ela:
# Alpine
RUN apk add --no-cache ca-certificates
# Debian/Ubuntu
RUN apt-get update && apt-get install -y ca-certificates
O outro caso é o proxy corporativo, que intercepta o
tráfego e reassina tudo com uma raiz própria. O sintoma é
self signed certificate in certificate chain em qualquer site,
inclusive nos grandes. Aí a saída certa é instalar a raiz da empresa como
confiável — não desligar a verificação.
Relógio errado derruba certificado válido
Certificado tem data de início e de fim, e a validação compara com o relógio local. Máquina com data errada — container sem sincronia, VM que dormiu, dispositivo sem bateria — recusa certificado perfeitamente válido, dizendo que expirou ou que ainda não vale.
date # confira antes de culpar o certificado
openssl s_client -connect dominio:443 < /dev/null 2>/dev/null \
| openssl x509 -noout -dates
Se o certificado está atrás de um proxy reverso, vale ler
como o TLS é
terminado — e o
diagnóstico de Nginx 502
cobre o caso do proxy_pass https que falha no handshake.