PSA: internet.nl “SHA1 hash function for key exchange” warning — why @SECLEVEL=2 in your cipher list is not enough (SNI gotcha)
I recently chased down a warning from the internet.nl mail server test and the root cause was subtle enough that I think it’s worth sharing. TL;DR: Postfix’s per-SNI TLS contexts (from smtpd_tls_server_sni_maps, which mailcow uses) do not inherit a @SECLEVEL=2 set via the cipher list, so clients that send SNI — which is internet.nl’s scanner and basically every real MTA — were still offered SHA-1 signatures even after I had “hardened” my TLS config. The reliable fix on Postfix 3.9+ is tls_config_file.
The symptom
internet.nl’s mail test reported:
At least one of your mail servers supports one or more insufficiently secure hash functions for key exchange.
Mail server (MX): mail.example.com — Outdated hash function: SHA1
This is about the digital signature the server produces during the TLS 1.2 key exchange (see RFC 9155), not about the certificate itself. The supported hash functions are a separate TLS setting (signature algorithms) and are not part of the cipher suite configuration.
Environment
- mailcow: dockerized, Postfix 3.10.12, OpenSSL 3.5.6
- ECDSA-only Let’s Encrypt certificate (prime256v1)
- TLS hardening via
data/conf/postfix/extra.cf
What I tried first (and why it looked like it worked)
The common advice is to raise the OpenSSL security level through the cipher list in extra.cf:
tls_high_cipherlist = ECDHE-ECDSA-AES256-GCM-SHA384:...:@SECLEVEL=2
At security level 2, OpenSSL refuses SHA-1 handshake signatures. And when I tested it, it seemed to work:
openssl s_client -connect 203.0.113.10:25 -starttls smtp -tls1_2 \
-sigalgs "ECDSA+SHA1" -cipher "DEFAULT:@SECLEVEL=0"
→ alert handshake failure (alert 40). SHA-1 rejected.
But internet.nl kept failing the test. I went down several rabbit holes (IPv4 vs IPv6 paths, docker-proxy source NAT, postscreen/tlsproxy vs smtpd) before spotting the actual pattern.
The actual pattern: SNI
openssl s_client sends SNI automatically when you connect by hostname, and sends no SNI when you connect by IP address. That was the hidden variable in all my testing:
# connect by IP → no SNI → SHA-1 REJECTED (looks fixed!)
openssl s_client -connect 203.0.113.10:25 -starttls smtp -tls1_2 \
-sigalgs "ECDSA+SHA1" -cipher "DEFAULT:@SECLEVEL=0"
# connect by hostname → SNI sent → SHA-1 ACCEPTED (what real clients get)
openssl s_client -connect mail.example.com:25 -starttls smtp -tls1_2 \
-sigalgs "ECDSA+SHA1" -cipher "DEFAULT:@SECLEVEL=0"
The second one completed the handshake and showed:
Peer signing digest: SHA1
Peer signature type: ecdsa_sha1
Explanation: mailcow manages certificates through smtpd_tls_server_sni_maps. When a client sends a matching SNI name, Postfix switches to a separate per-certificate SSL context — and that context does not get the security level you set through tls_high_cipherlist on the default context. So the hardening only applied to clients that send no SNI, i.e. almost nobody in the real world. internet.nl connects to your MX by name, with SNI, so it always saw the lenient context.
You can prove it to yourself with two tests where SNI is the only difference:
# force SNI on an IP connection → SHA-1 accepted
openssl s_client -connect 203.0.113.10:25 -servername mail.example.com -starttls smtp \
-tls1_2 -sigalgs "ECDSA+SHA1" -cipher "DEFAULT:@SECLEVEL=0"
# suppress SNI → alert 40
openssl s_client -connect mail.example.com:25 -noservername -starttls smtp \
-tls1_2 -sigalgs "ECDSA+SHA1" -cipher "DEFAULT:@SECLEVEL=0"
The fix (Postfix 3.9+)
Restrict signature algorithms at the OpenSSL configuration level, which applies to every context Postfix creates, SNI contexts included. Postfix 3.9 added tls_config_file for exactly this.
- Create
data/conf/postfix/openssl_tls.cnf (this directory is already mounted into the container as /opt/postfix/conf):
openssl_conf = openssl_init
[openssl_init]
ssl_conf = ssl_sect
[ssl_sect]
system_default = system_default_sect
[system_default_sect]
SignatureAlgorithms = ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:ed25519:ed448:RSA-PSS+SHA256:RSA-PSS+SHA384:RSA-PSS+SHA512:RSA+SHA256:RSA+SHA384:RSA+SHA512
(Everything modern; SHA-1 and MD5 simply absent.)
- Add to
data/conf/postfix/extra.cf:
tls_config_file = /opt/postfix/conf/openssl_tls.cnf
- Restart:
docker compose restart postfix-mailcow
- Verify — both of these should now fail with alert 40:
openssl s_client -connect mail.example.com:25 -starttls smtp -tls1_2 \
-sigalgs "ECDSA+SHA1" -cipher "DEFAULT:@SECLEVEL=0"
openssl s_client -connect 203.0.113.10:25 -servername mail.example.com -starttls smtp \
-tls1_2 -sigalgs "ECDSA+SHA1" -cipher "DEFAULT:@SECLEVEL=0"
After this, internet.nl gave me 100% again.
Caveats
tls_config_file applies process-wide, so Postfix’s outbound SMTP client will also refuse SHA-1 handshake signatures from very old remote servers during opportunistic TLS. I consider that acceptable, but keep it in mind if you ever debug a delivery problem to some ancient server.
- If you test your own server with
openssl s_client, always test with -servername (or by hostname). Testing against a raw IP silently skips SNI and can show you a different TLS policy than real clients get — that cost me a few hours.
- Your client machine’s own OpenSSL may refuse to even offer SHA-1 at its default security level (“no ciphers available” before the connection is made) — that’s what the
-cipher "DEFAULT:@SECLEVEL=0" on the client side is for.
Hope this saves someone the detour!