Kleines Update zur Mini-Saga rund um Post-Quantum-TLS auf Postfix. Zur Erinnerung: Im Original-Beitrag vom Februar stand tls_eecdh_auto_curves noch global in der main.cf, mit X25519MLKEM768 an erster Stelle. Im Nachtrag vom 1. April im selben Beitrag kam dann die Trennung in master.cf, weil die globale Variante einen ClientHello jenseits 1400 Bytes erzeugt und damit gegen manche Zielserver auf die Nase fällt. Dank smtp_tls_security_level = may fällt Postfix in dem Fall stillschweigend auf Plaintext zurück, eure Mail geht raus, aber unverschlüsselt. Klingt akademisch, ist es nicht.
Mit dem letzten regulären pkg upgrade auf FreeBSD 15.0-RELEASE-p7 ist Postfix 3.11.1 eingezogen. Damit erledigen sich beide Workarounds. Der Built-in-Default ist jetzt korrekt, und der ganze master.cf-Override-Block aus dem April darf ersatzlos raus. Wer beide Vorgängerbeiträge nachgebaut hat, darf jetzt rückwärts wieder aufräumen — mit zwei postfix reload ist man durch.
Was Postfix 3.11.1 mitbringt
Postfix 3.11.1 setzt den Built-in-Default für tls_eecdh_auto_curves auf:
tls_eecdh_auto_curves = ?X25519MLKEM768:DEFAULT
Das Fragezeichen vor dem Gruppennamen ist OpenSSL-3.5+ Syntax für Delayed Key-Share, Postfix reicht das 1:1 durch. Bedeutung: X25519MLKEM768 wird in der TLS-Extension supported_groups annonciert, der eigentliche KeyShare aber NICHT vorab im ClientHello generiert. Der KeyShare wird erst materialisiert, wenn der Server per HelloRetryRequest gezielt danach fragt. ClientHello bleibt damit klein, MLKEM wird trotzdem ausgehandelt sobald die Gegenstelle es unterstützt — und fällt sauber auf eine klassische Kurve zurück wenn nicht.
Damit erledigt sich die Begründung für die Client/Server-Trennung aus dem April-Update. Inbound und Outbound können denselben Default nutzen — kein Bloat in eine Richtung, kein Verzicht auf PQC in die andere.
master.cf aufräumen
Aus master.cf werden alle -o tls_eecdh_auto_curves=... Overrides ersatzlos entfernt. Im konkreten Setup hier waren das fünf Stellen: smtp/inet, submission/inet, der eigene 2525/inet-Listener, smtps/inet und smtp/unix für Outbound. Diese Zeilen alle raus:
smtp inet n - n - - smtpd -o tls_eecdh_auto_curves=X25519MLKEM768,X25519,prime256v1,secp384r1 # raus submission inet n - n - - smtpd -o tls_eecdh_auto_curves=X25519MLKEM768,X25519,prime256v1,secp384r1 # raus smtps inet n - n - - smtpd -o tls_eecdh_auto_curves=X25519MLKEM768,X25519,prime256v1,secp384r1 # raus smtp unix - - n - - smtp -o tls_eecdh_auto_curves=X25519,X25519MLKEM768,prime256v1,secp384r1 # raus
In der main.cf wird ebenfalls nichts mehr explizit gesetzt — der Built-in-Default greift. Verifikation:
# postconf tls_eecdh_auto_curves tls_eecdh_auto_curves = ?X25519MLKEM768:DEFAULT # postconf -P | grep tls_eecdh_auto_curves (leer -- kein Override aktiv)
Anschließend postfix reload. Kein Restart, kein Service-Ausfall, keine offenen Verbindungen verloren. Dovecot bleibt unverändert, dort sieht es weiter so aus:
ssl_curve_list = X25519MLKEM768:X25519:prime256v1:secp384r1
Dovecot ist nur Server, kein Outbound-Client. Da gibt es kein ClientHello-Bloat-Problem.
Was DEFAULT konkret enthält
Hinter dem schlichten Wort DEFAULT versteckt OpenSSL 3.5.4 eine konkrete Liste. Ich habe sie aus dem extension_type=supported_groups-Block eines Test-Handshakes herausgezogen:
X25519MLKEM768 (4588) # mit ?-Prefix vom davorgestellten Eintrag, dedupliziert X25519 (29) secp256r1 (23) # prime256v1 X448 (30) secp384r1 (24) secp521r1 (25) ffdhe2048 (256) ffdhe3072 (257)
Damit ist klar warum man den Default in der Regel ungeändert lässt — er enthält MLKEM768 hybrid an erster Stelle, alle gängigen klassischen Kurven dahinter und ein paar FFDHE-Gruppen als Backup. Wer es minimaler will und auf FFDHE verzichten kann, setzt explizit ?X25519MLKEM768:X25519:prime256v1:secp384r1. Notwendig ist das nicht.
Verifikation #1: ClientHello-Größen mit openssl s_client
Um das Delayed-Key-Share-Verhalten ohne Glaubensfrage zu prüfen, vier verschiedene Group-Listen gegen einen MLKEM-fähigen MX gefahren — Gmail tut sich da als Test-Target ganz gut. Die Schleife direkt zum Nachstellen:
for groups in
'?X25519MLKEM768:DEFAULT'
'X25519MLKEM768:X25519:prime256v1:secp384r1'
'X25519:X25519MLKEM768:prime256v1:secp384r1'
'X25519'; do
echo "=== groups=$groups ==="
openssl s_client -connect gmail-smtp-in.l.google.com:25 -starttls smtp
-groups "$groups" -msg </dev/null 2>&1
| grep 'Handshake.*ClientHello'
done
In der -msg-Ausgabe steht pro Handshake-Record eine Zeile wie
>>> TLS 1.3, Handshake [length 014e], ClientHello
Die hex-Length ist die Größe des TLS-Records. Tabellarisch:
| Group-Liste | Initial ClientHello | Nach HRR |
|---|---|---|
?X25519MLKEM768:DEFAULT (Postfix 3.11 Default) | 334 Bytes (0x014e) | 1518 Bytes (0x05ee) |
X25519MLKEM768:X25519:prime256v1:secp384r1 (alt-Inbound) | 1510 Bytes (0x05e6) | — |
X25519:X25519MLKEM768:prime256v1:secp384r1 (alt-Outbound) | 326 Bytes (0x0146) | 1510 Bytes (0x05e6) |
X25519 (klassisch ohne PQC) | 320 Bytes (0x0140) | — |
Interpretation: Der neue Default in Zeile 1 verhält sich praktisch identisch zum alten Outbound-Workaround in Zeile 3 — kleines Initial-ClientHello mit nur klassischen KeyShares, MLKEM wird erst über HelloRetryRequest aktiviert. Der alte Inbound-Style in Zeile 2 hingegen pusht den rund 1184 Byte großen ML-KEM-768-PublicKey schon im ersten ClientHello mit und sprengt damit die 1400-Byte-Schwelle, an der einige Mail-Frontends regelmäßig stoppen. Genau das Verhalten, das im Original-Beitrag und im April-Update als Problem identifiziert wurde — jetzt nativ vermieden. Ich muss ja gerade etwas grinsen …
Meine Frau ist immer mal wieder so cool und liest mir meine neuen Beiträge vor. So höre ich sie noch einmal, und ihr Feedback ist mir sehr wichtig.
Sie versteht aber im Grunde kein Wort und ersetzt jegliche Codeblöcke oder zu technische Specs immer durch „bla / irgendwas“.
Ich glaube, dieser Beitrag wird einer der schwierigeren. Dennoch wird sie ihn für mich lesen — und das ist toll. 😀
Danke!
Verifikation #2: tcpdump auf dem Wire
Wer dem OpenSSL-Output nicht glaubt, kann sich das ClientHello auch direkt vom Draht holen. Damit der Capture funktioniert, sind zwei Stolpersteine zu beachten:
- tcpdump muss auf dem Host laufen, nicht in der Jail. In FreeBSD-Jails ist der direkte BPF-Zugriff aufs Interface defaultmäßig abgeschaltet (
Packet capture is not supported on that device). tcpdump auf der Host-Seite sieht den Jail-Verkehr aber sauber über das geteilte Interface. - openssl s_client greift per Default zu IPv6, wenn der Zielhost AAAA-Records hat. Ein reiner IPv4-Filter bleibt dann leer (
0 packets captured / 689 packets received by filter— ich gestehe, der Moment war kurz verwirrend). Lösung: openssl mit-4zwingen oder den Filter dual-stack auslegen. Ja klar, ist ja eine selbstverständliche Kleinigkeit. Sehe ich auch so. Nur warum habe ich dann fünf Minuten lang mit komplett leerem Blick wie ein Eichhörnchen auf meinen Monitor geschaut und nicht verstanden, warum ich keine Packets sehe? Bis zu diesem Moment habe ich mich ja für einen kurzen Augenblick cool gefühlt, weil ich dachte, das alles so weit verstanden zu haben. Tja, und dann? Dann fünf Minuten lang: „Kein Anschluss unter dieser Nummer …“
Konkret im Test gelaufen (smtp-Jail mit IPv4 148.251.30.205, IPv6 2a01:4f8:262:4716::25, Interface igb0):
tcpdump -i igb0 -n -s 0 -c 8 -w /tmp/ch.pcap 'dst port 25 and (host 148.251.30.205 or host 2a01:4f8:262:4716::25)' & jexec smtp openssl s_client -4 -connect gmail-smtp-in.l.google.com:25 -starttls smtp -groups '?X25519MLKEM768:DEFAULT' </dev/null
Anschließend pcap auslesen:
tcpdump -nn -tttt -r /tmp/ch.pcap # Paketsequenz tcpdump -nn -r /tmp/ch.pcap -X # Hex-Dump für TLS-Record-Inspektion
Gekürzte Sequenz:
18:35:59.443 IP 148.251.30.205.10420 > 142.251.127.27.25: Flags [S], length 0 18:35:59.448 IP Flags [.], length 0 18:35:59.525 IP Flags [P.], length 23: SMTP: EHLO ... 18:35:59.541 IP Flags [P.], length 10: SMTP: STARTTLS 18:35:59.554 IP Flags [P.], length 339: SMTP
Das letzte Paket mit 339 Byte Payload enthält das ClientHello. Hex-Auszug aus tcpdump -X:
0x0030: ...1603 0101 4e 01 0001 4a 03 0354 0x0040: 045a 83dd 0481 ee15 c537 75f2 6b38 f360 ...
Aufgeschlüsselt:
0x16 -- TLS Record Type Handshake 0x0301 -- TLS Version (legacy in TLS 1.3 ClientHello) 0x014e -- TLS Record Length 334 Bytes 0x01 -- Handshake Type ClientHello 0x00014a -- ClientHello-Body 330 Bytes
339 Byte TCP-Payload teilen sich auf in 5 Byte TLS-Record-Header und 334 Byte Body. Das passt locker in ein einziges TCP-Segment, weit unter der 1400-Byte-Schwelle. Damit ist das ?-Präfix-Verhalten auch auf dem Wire bestätigt.
Verifikation #3: eigene Inbound-Seite von extern
Zum Abschluss von einem zweiten FreeBSD-Host aus — also nicht aus der smtp-Jail selbst, sondern als komplett externer Client — gegen alle relevanten Ports getestet:
for entry in 25:smtp 465: 587:smtp 993: 4190:sieve; do
port=${entry%:*}; starttls=${entry#*:}
host=smtp.kernel-error.de
case $port in 993|4190) host=imap.kernel-error.de ;; esac
if [ -z "$starttls" ]; then args="-connect $host:$port"
else args="-connect $host:$port -starttls $starttls"; fi
echo "--- Port $port ---"
echo QUIT | openssl s_client $args -brief 2>&1 | grep -E 'Negotiated|Verification:'
done
Ergebnis für jeden der fünf Ports identisch:
Verification: OK Negotiated TLS1.3 group: X25519MLKEM768
Postfix-Inbound auf 25/465/587 und Dovecot auf 993/4190 verhandeln durchweg X25519MLKEM768. Der Default greift für smtpd genauso wie für smtp outbound, ohne dass irgendwo noch eine Trennung händisch erzwungen werden muss.
Fazit
Mit Postfix 3.11 und OpenSSL 3.5 ist sowohl die main.cf-globale Variante aus dem Original-Beitrag als auch die master.cf-Trennung aus dem April-Update Geschichte. Der Built-in-Default ?X25519MLKEM768:DEFAULT liefert genau das Verhalten, das ich vorher manuell aufgebaut hatte: kleiner ClientHello outbound, MLKEM via HelloRetryRequest, klassischer Fallback wo nötig.
Konkret: wer den Original-Beitrag oder den April-Update nachgebaut hat, kann nach dem pkg-Upgrade auf Postfix 3.11.x die tls_eecdh_auto_curves-Einträge ersatzlos rausnehmen — aus main.cf und/oder master.cf — und mit postfix reload aktivieren. openssl s_client -msg und ein kurzer tcpdump bestätigen anschließend, dass das initiale ClientHello tatsächlich klein bleibt und MLKEM über HRR aushandelt.
Drei Iterationen, ein Default, der am Ende einfach passt. So darf das gerne häufiger laufen.
Siehe auch: Post-Quantum TLS für E-Mail (Original-Beitrag mit April-Nachtrag), Post-Quantum TLS für Nginx, Post-Quantum TLS auf Nginx: 15 Tage $ssl_curve ausgewertet und der Mailinglisten-Thread auf postfix-users, der die ganze Saga überhaupt erst angestoßen hat.
Wie immer: bei Fragen, fragen.
















