IT security, FreeBSD, Linux, mail server hardening, post-quantum crypto, DNS, retro computing & hands-on hardware hacks. Privater Tech-Blog seit 2003.

Postfix 3.11.1 mit OpenSSL 3.5: Post-Quantum-TLS jetzt nativ — meine alten Workarounds dürfen raus

Postfix 3.11.1 mit OpenSSL 3.5 – Post-Quantum TLS (X25519MLKEM768) mit kleinem ClientHello durch Delayed Key Share

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-ListeInitial ClientHelloNach 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 -4 zwingen 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.

5 Kommentare

  1. Hagen Krämer

    Servus Sebastian,

    danke für den Followup, hatte den master.cf-Block aus deinem April-Update genau so übernommen und freue mich jetzt das Ding wieder aufzuräumen. Bei mir auf FreeBSD 14.2 ist Postfix allerdings noch auf 3.10.x in den Quarterly-Packages, der 3.11.1 ist erst über den Latest-Tree drin. Hast du eine Erfahrung damit ob das Switchen auf Latest quartalsweise stabil bleibt? Möchte nicht wegen Postfix die ganze Pkg-Tree umstellen.

    Kleine Anmerkung zum Postcheck: bei mir hat ein „service postfix reload“ nicht ausgereicht, ich musste tatsächlich einmal „postfix reload“ als root direkt aufrufen damit der neue Default griff. Vielleicht eine Macke meiner Installation, vielleicht ein Hinweis für andere.

    Viele Grüße
    Hagen

    • Kernel-Error

      Hallo Hagen,

      bei mir läuft Latest auf den Mailservern seit über zwei Jahren, kein Drama. Wichtig ist nur dass Du bei den anderen Quarterly-Hosts nicht versehentlich Latest-Pakete drüberbügelst, das gibt dann Versions-Drift. Wenn Du ausschließlich Postfix vorziehen willst ist ports-mgmt/poudriere mit eigenem Build aus den Ports die saubere Option, dann bleiben alle anderen Pakete auf Quarterly.

      Zum service-Reload-Thema: das ist mir auch schon untergekommen, allerdings nicht reproduzierbar. Mein Verdacht ist dass das rc-Skript bei bestimmten States den reload als no-op interpretiert. Direktes „postfix reload“ ist da der zuverlässigere Weg, decken wir uns also.

      Gruß
      Sebastian

  2. flo23

    moin,

    interessanter post. eine beobachtung von mir: der ?-prefix für delayed key-share scheint bei einigen alten oder buggy receivern probleme zu machen. ich habe nach dem upgrade auf postfix 3.11 sporadische delivery-fails an genau eine domain gesehen wo der server beim HRR nicht mehr antwortet. workaround war den ?-prefix für genau diese domain wieder rauszunehmen, hat 3 tage gedauert bis ich das eingegrenzt hatte.

    vielleicht ist das ein einzelfall, vielleicht aber auch was zum aufpassen. die domain ist ein deutscher mittelstand mit eigener mta, kein billiger hoster.

    flo

    • Kernel-Error

      Hallo Flo,

      danke für den Hinweis, das ist tatsächlich genau die Art Edge-Case die mich noch beschäftigt. Hast Du eine Vermutung was der Server konkret macht? Antwortet er gar nicht, oder kommt eine TLS-Fehlermeldung im Log? Falls Du die Domain nicht öffentlich nennen willst, gerne per Mail an mich.

      Kleine Variante zum Workaround: statt den ?-Prefix komplett zu entfernen kannst Du auch in master.cf einen smtp-Override für diese eine Strecke via tls_per_site oder smtp_tls_policy_maps mit TLS-Policy ohne den Prefix bauen. Dann bleibt der Default global PQC-fähig und nur diese eine Domain fällt zurück. Hat hier bei einer ähnlichen Konstellation gegen ein altes Cisco IronPort funktioniert.

      Gruß
      Sebastian

  3. Tom Schäfer

    Hallo Sebastian,

    bei mir laeuft das alles auf einem Plesk-Server (Ubuntu 22.04 darunter). Plesk ueberschreibt mir die main.cf und master.cf bei jedem Update wieder. Hast du eine Idee wie ich das mit der Plesk-eigenen Konfig zum Laufen bekomme oder ist das ueber Plesk schlicht nicht moeglich?

    Danke und Gruss
    Tom

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

© 2026 -=Kernel-Error=-RSS

Theme von Anders NorénHoch ↑