In meiner Serie BIOS erklärt arbeite ich mich durch das Setup eines Supermicro X12SPi-TF. Die Grundlage dafür war bisher eine Bildschirmaufnahme: ich bin durch alle Menüs gelaufen, habe das Video in Einzelbilder zerlegt und die Werte abgetippt. Das funktioniert, ist aber Handarbeit, und Handarbeit übersieht Dinge.
Was ich eigentlich wollte, ist eine Datei. Eine Liste aller Optionen mit ihrem aktuellen Wert, maschinenlesbar, damit ich sie durchsuchen und mit einem späteren Stand vergleichen kann. Das Board kann das. Es rückt es nur nicht heraus.
Drei Wege, drei Absagen
Der erste Weg führt über Redfish, die HTTP-Schnittstelle des Management-Controllers. Der passende Endpunkt existiert, antwortet aber nicht mit Daten:
GET /redfish/v1/Systems/1/Bios
HTTP 403
"MessageId": "SMC.1.0.OemLicenseNotPassed",
"Message": "Not licensed to perform this request. The following licenses DCMS were needed"
Der zweite Weg ist Supermicros eigenes Kommandozeilenwerkzeug. Es heißt inzwischen SAA, SuperServer Automation Assistant, und liegt dem BIOS-Paket bei. Es kann genau das, was ich suche, und arbeitet dabei nicht über das Netz, sondern direkt über die Firmware:
./saa -c GetCurrentBiosCfg --file bios-current.xml
ExitCode 80
Node product key is not activated.
One of the node product key (SFT-OOB-LIC or SFT-DCMS-SINGLE) should be activated
Der dritte Weg wäre, die UEFI-Variablen selbst zu lesen. Die liegen unter /sys/firmware/efi/efivars/, und tatsächlich gibt es dort Einträge, die nach BIOS-Einstellungen aussehen. Nur sind das undurchsichtige Datenblöcke ohne die Zuordnungstabelle des Boards. Man sieht Bytes, aber man weiß nicht, welches Byte welcher Menüpunkt ist.
Drei unabhängige Wege, dieselbe Sperre. An dieser Stelle habe ich in meinen Notizen etwas geschrieben, das sich später als falsch herausstellte. Dazu komme ich am Ende.
Zwei Lizenzen, und sie sind nicht dasselbe
Lies die beiden Fehlermeldungen oben noch einmal nebeneinander. Sie verlangen nicht das Gleiche. Redfish nennt namentlich DCMS. SAA nennt SFT-OOB-LIC oder SFT-DCMS-SINGLE, also eine von zweien.
Das ist der Punkt, an dem man leicht zu viel Geld ausgibt. Supermicro verkauft für diese Board-Generation zwei Lizenzen. Die kleine heißt Out of Band, kurz OOB. Die große heißt DCMS und kostet etwa das Fünffache. Wer nur die Fehlermeldung von Redfish liest, kauft DCMS. Wer die Fehlermeldung von SAA liest, merkt, dass es für seinen Zweck auch die kleine tut.
Nebenbei taucht dieselbe Lizenz an einer Stelle auf, die ich nicht erwartet hätte. Im Handbuch zum Board stehen bei zwei Sicherheitseinstellungen, nämlich beim Abschalten der vorderen und der hinteren USB-Anschlüsse, die Worte *available for configuration if the SFT-DCMS-SINGLE license is installed*. Es fehlen also nicht nur Management-Schnittstellen. Es fehlen zwei Menüpunkte im Setup selbst.
Warum man den Schlüssel nicht selbst baut
Bei dieser Recherche stößt man unweigerlich auf ein Werkzeug auf GitHub, das Supermicro- Produktschlüssel behandelt. Für die Generationen 9 bis 11 kann es welche erzeugen, denn dort war der Schlüssel ein berechneter Wert über die MAC-Adresse des Management-Controllers.
Für die zwölfte Generation, also mein Board, kann es das nicht, und das Projekt schreibt es selbst deutlich hin: dieses Format lässt sich mit dem Werkzeug prüfen, aber nicht erzeugen. Der Grund ist, dass Supermicro umgestellt hat. Der Schlüssel ist heute eine von Supermicro signierte Datei, und der Management-Controller prüft die Signatur gegen einen öffentlichen Schlüssel in seiner eigenen Firmware. Ohne den privaten Schlüssel des Herstellers entsteht da nichts Gültiges.
Mein Board bestätigt das Format auf Nachfrage:
Node Product Key Format..........JSON
Damit ist die naheliegende Abkürzung nicht moralisch, sondern rechnerisch verschlossen. Man muss die Frage also wirklich beantworten.
Der Kauf, und eine Überraschung beim Erzeugen
Gekauft habe ich im Supermicro eStore. 23,67 Euro netto, 28,17 Euro brutto. Bei der Bestellung wählt man das Mainboard-Modell aus einer Liste, und mein X12SPi-TF steht darin. Das ist erwähnenswert, weil mehrere Händlerbeschreibungen dieser Lizenz nur X10 und X11 nennen. Diese Texte sind veraltet.
Jetzt kommt der Teil, den ich falsch erwartet hatte. Ich dachte, ich kaufe und bekomme einen Schlüssel. So läuft es nicht. Man bekommt zunächst nur eine Zertifikatsnummer und die Aufforderung, den Schlüssel selbst zu erzeugen. Dafür meldet man sich im Store an, wählt die Bestellung aus und gibt an, für welches Gerät der Schlüssel gelten soll: entweder die MAC-Adresse des Management-Controllers oder die Seriennummer des Boards.
Die Bindung an die Hardware entsteht also nicht beim Verkauf, sondern beim Käufer, im Moment des Erzeugens. Das ist konsequent, denn Supermicro weiß beim Verkauf ja gar nicht, in welchem Gerät die Lizenz landen soll. Es heißt aber auch, dass man sich vertippen kann, und dann hat man einen Schlüssel für ein Gerät, das es nicht gibt.
Der Ablauf war zügig. Bestellbestätigung um 14:17, die Freigabe zum Erzeugen um 14:42, der fertige Schlüssel um 14:44, aktiv im Board um 14:46. Unter einer halben Stunde vom Klick bis zur Funktion, und die zugesagte Stunde war damit nicht zu optimistisch.
Der Schlüssel selbst ist eine kleine Textdatei, benannt nach der MAC-Adresse, mit einem JSON-Objekt darin: Lizenzname, Ausstellungsdatum und eine lange Signatur. Mehr ist es nicht.
Eine Kuriosität am Rande: die Mail mit dieser Textdatei trägt einen Ausfuhrhinweis der US-Regierung. Die Ware sei nur für das Bestimmungsland freigegeben und dürfe nicht weiterveräußert oder an Dritte weitergegeben werden. Das gilt hier für eine Datei von wenigen hundert Byte, deren einzige Wirkung darin besteht, in einem Verwaltungscontroller einen Menüpunkt sichtbar zu machen.
Aktivieren
Supermicro nennt vier Wege, den Schlüssel einzuspielen: die Weboberfläche des Management-Controllers, das Werkzeug SUM, die Verwaltungssoftware SSM und Redfish. Die Weboberfläche ist der ruhigste Weg, weil sie eine Bestätigungsseite hat, und bei einem Schlüssel, der genau einmal auf genau ein Gerät passt, will man die haben.
Wer es lieber skriptet, findet die passenden Endpunkte im Management-Controller, und die sind auch ohne Lizenz erreichbar:
POST /redfish/v1/Managers/1/LicenseManager/Actions/LicenseManager.ActivateLicense
POST /redfish/v1/Managers/1/LicenseManager/Actions/LicenseManager.ClearLicense
GET /redfish/v1/Managers/1/LicenseManager/QueryLicense
Die Abfrage danach gibt genau den Inhalt der Datei zurück, die man hochgeladen hat. Der Controller speichert den Schlüssel also unverändert und prüft ihn bei Bedarf.
Das Kommando, auf das es ankommt
Nach der Aktivierung meldet SAA einen anderen Zustand:
Und dasselbe Kommando, das vorher mit ExitCode 80 abgebrochen ist, läuft durch:
./saa -c GetCurrentBiosCfg --file bios-current.xml
File "bios-current.xml" is created.
Heraus kommt eine Datei von 260 Kilobyte mit 60 Menüs und 372 Einstellungen. Zum Vergleich: das offizielle Handbuch beschreibt 180 Optionen. Und die Datei enthält pro Eintrag mehr, als man im Setup sieht, nämlich zusätzlich den Auslieferungszustand und den Hilfetext:
Der Auslieferungszustand ist der eigentliche Gewinn. Damit sehe ich auf einen Blick, welche Einstellungen an dieser Maschine vom Werkszustand abweichen, und das ist genau die Liste, die man nach einem BIOS-Update wiederherstellen will.
Zwei komplette Hauptmenüs standen darin, die ich beim Durchklicken schlicht nie geöffnet hatte. Und ein Untermenü, das ich in meinen Notizen als offene Lücke geführt habe, war vollständig enthalten. So viel zum Thema Handarbeit übersieht Dinge.
Was die Lizenz nicht freischaltet
Jetzt der ehrliche Teil, und der ist mir wichtiger als der Erfolg. Ich hatte vor dem Kauf drei Messungen aufgeschrieben, damit ich hinterher vergleichen kann. Drei von vier Erwartungen sind eingetroffen, und die vierte war die interessante.
Der Redfish-Endpunkt, mit dem alles anfing, antwortet weiterhin mit 403 und verlangt weiterhin DCMS. Die kleine Lizenz öffnet den Weg über SAA und eben nicht den über Redfish. Wer also ausdrücklich die Netzwerkschnittstelle braucht, etwa weil er hunderte Server ohne Betriebssystem konfigurieren will, kommt um die große Lizenz nicht herum.
Das hat eine Nebenwirkung, die ich nicht auf dem Zettel hatte. Der Firmware-Updater fwupd kann inzwischen mit Supermicros Redfish-Schnittstelle sprechen. Er sieht bei mir aber nur zwei von zwölf Firmware-Komponenten, und die beiden BIOS-Einträge fehlen. Der Grund ist, dass er zur Identifikation eines Geräts das Objekt lesen muss, das hinter genau diesem gesperrten Endpunkt liegt. Die Lizenz sperrt hier also nicht das Aktualisieren, sondern das Erkennen, und das Aktualisieren scheitert dann als Folge. Nach der Aktivierung sind es unverändert zwei von zwölf.
Und der dritte Weg, den ich vorher gar nicht gesehen hatte: fwupd sieht den BIOS-Speicher auch ganz direkt am Chipsatz, liest die Version korrekt aus und verweigert trotzdem den Dienst:
Internal SPI Controller (BIOS):
Aktuelle Version: 2.5
Probleme: Device firmware has been locked
Das ist keine Lizenzsache, und daran hat sich erwartungsgemäß nichts geändert. Der Speicher ist auf Chipsatzebene schreibgeschützt, weil das Board mit aktivem Root of Trust läuft. Ein BIOS, das sich aus einem laufenden Betriebssystem heraus überschreiben lässt, wäre genau die Lücke, die dieser Schutz verhindern soll. Der Reflex sagt hier Hersteller blockiert Linux. Tatsächlich blockiert eine Sicherheitsfunktion, die ich selbst haben will.
Mein Denkfehler
Bleibt der Satz, den ich weiter oben angekündigt habe. In meinen Notizen stand nach der dritten Absage sinngemäß: drei unabhängige Wege, dieselbe Sperre, nicht erneut versuchen.
Gemessen hatte ich: ohne Lizenz gibt es keinen Weg. Aufgeschrieben hatte ich: es gibt keinen Weg. Und die dritte Möglichkeit, nämlich die Lizenz einfach zu kaufen, kam in meiner Aufzählung der drei gesperrten Wege überhaupt nicht vor, obwohl das Werkzeug sie mir wörtlich genannt hatte.
Dass drei Wege an derselben Sperre scheitern, beweist, dass es eine Sperre gibt. Es beweist nicht, dass sie unüberwindbar ist. Wenn eine Fehlermeldung den Namen der fehlenden Berechtigung nennt, ist das ein Hinweis und kein Schlusspunkt.
Lohnt sich das?
Für einen einzelnen Rechner zu Hause: nur, wenn man Freude daran hat. Ich hätte die Werte auch weiter abschreiben können, und der Screencast hat mir immerhin eine fünfzehnteilige Serie eingebracht, die aus einer XML-Datei nie entstanden wäre. Man liest eine Datei nicht so aufmerksam wie ein Menü, durch das man sich selbst klicken muss.
Für alles, was mehr als eine Handvoll Maschinen betrifft, sieht es anders aus. Konfiguration auslesen, versionieren, nach einem Update wieder einspielen und im Zweifel gegen einen bekannten Stand vergleichen, das ist bei 28 Euro pro Board keine Diskussion. Ärgerlich finde ich nur, dass man diese Rechnung überhaupt aufmachen muss. Es geht um das Auslesen der eigenen Einstellungen auf der eigenen Hardware.
Wenn du auf demselben Board sitzt und dieselbe Fehlermeldung vor dir hast, ist die Kurzfassung: für die Konfiguration reicht die kleine Lizenz, für Redfish brauchst du die große, und für Firmware-Updates brauchst du bei einem X12 mit Root of Trust vermutlich gar keine.
Seit Ende Juli steht bei mir ein anderer Rechner unter dem Schreibtisch. Kein gekaufter, sondern einer aus Teilen die ohnehin schon da waren: ein Supermicro X12SPi-TF, also ein richtiges Serverboard, dazu ein Xeon Gold 5315Y und 256 GB registrierter ECC-Speicher. Die Grafikkarte ist aus der alten Workstation umgezogen, Gehäuse und Netzteil waren neu. Seitdem macht die Kiste genau das, was vorher eine deutlich ältere Dual-Socket-Maschine gemacht hat, nur leiser und mit mehr Luft nach oben.
Beim Einrichten ist mir dann etwas passiert, das ich so nicht auf dem Zettel hatte. Ich saß im BIOS-Setup, bin die Seiten durchgegangen, und bei einigen Einstellungen dachte ich: kenne ich, habe ich schon hundertmal gesehen. Hätte mich in dem Moment jemand gefragt, was der Schalter denn nun genau tut, wäre ich ins Schwimmen gekommen. Also habe ich nachgeschlagen. Und dann noch mal. Irgendwann stand fest, dass daraus eine kleine Serie werden sollte.
Warum ein Server-BIOS eine andere Hausnummer ist
Ein normales Desktop-Board hat im Setup vielleicht drei Dutzend Schalter, die wirklich etwas bewirken. Der Rest ist Bootreihenfolge, Lüfterkurve und Beleuchtung. Bei diesem Board sieht das anders aus. Das Handbuch beschreibt rund 180 Einstellungen, bei denen man tatsächlich etwas auswählen kann, verteilt auf sieben Hauptmenüs und je nach Zweig bis zu fünf Ebenen tief. Dort stehen Dinge wie Patrol Scrub, Stale AtoS, UMA-Based Clustering oder IIO eDPC Support. Das sind keine Marketingbegriffe, das sind Schalter für Verhalten, das man verstanden haben muss, bevor man daran dreht.
Und genau da liegt für mich der interessante Punkt. Fast alles davon steht auf Auto oder auf dem Herstellerdefault, und das ist in den allermeisten Fällen auch völlig richtig so. Nur ist „steht auf Default“ eben keine Antwort auf die Frage, was eigentlich passiert, wenn man es umstellt. Diese Antwort hätte ich gern. Für jeden Schalter, der auf diesem Board etwas tut.
Wie man auf so einem Board überhaupt ins Setup kommt
Hier fängt der Unterschied zum Desktop schon an. Auf dem Board sitzt ein zweiter, kleiner Computer: der BMC, ein ASPEED AST2600. Der hat seine eigene Netzwerkbuchse, seine eigene IP-Adresse, sein eigenes Betriebssystem, und er läuft auch dann, wenn der eigentliche Rechner aus ist. Solange Strom am Netzteil anliegt, ist der BMC wach. Der BMC bekommt später eine eigene Folge, für heute reicht eine seiner Fähigkeiten: KVM over IP.
Das heisst konkret, ich brauche für das BIOS weder Monitor noch Tastatur am Rechner. Ich rufe im Browser die Weboberfläche des BMC auf, starte dort die Konsole, und sehe das Bild, das die Grafikausgabe gerade produziert. Inklusive POST, inklusive Setup, inklusive Bootloader. Tasten gehen den umgekehrten Weg. Der BMC hängt sich dafür als virtuelle USB-Tastatur und -Maus an den Rechner, und die tauchen unter Linux später auch brav in lsusb auf:
Bus 001 Device 003: ID 0557:9241 ATEN International Co., Ltd SMCI HID KM
Bus 001 Device 004: ID 0b1f:03ee Insyde Software Corp. RNDIS/Ethernet Gadget
Wer schon mal einen Server im Keller stehen hatte und sich gefragt hat, wie man da ohne Bildschirm ins BIOS kommt: so. Und weil das Bild ohnehin durchs Netz geht, kann man es auch gleich aufzeichnen. Genau das habe ich gemacht, und diese Aufnahme ist die Grundlage für alles, was in dieser Serie noch kommt.
Und dann wollte ich die Einstellungen einfach auslesen
Das war der Moment, in dem die Serie ihre eigentliche Rechtfertigung bekommen hat. Meine Idee war naheliegend: statt 200 Screenshots zu sortieren, hole ich mir die komplette Konfiguration als Datei. Moderne Boards können das, dafür gibt es Redfish, eine HTTP-Schnittstelle auf dem BMC. Ein Aufruf, und man hat jede Einstellung mit Ist-Wert und allen möglichen Werten sauber als JSON.
Der Aufruf ging auch durch. Nur nicht so, wie ich wollte:
GET /redfish/v1/Systems/1/Bios
403 Forbidden
MessageId: SMC.1.0.OemLicenseNotPassed
"Not licensed to perform this request. The following licenses DCMS were needed"
Also der Weg über das Kommandozeilenwerkzeug. Supermicro liefert dafür den SuperServer Automation Assistant mit, und der kennt genau den passenden Befehl. In-Band, also lokal auf der Maschine selbst, ohne Umweg über das Netzwerk:
ExitCode = 80
Description = Node product key is not activated.
Error message:
One of the node product key (SFT-OOB-LIC or SFT-DCMS-SINGLE) should be
activated to execute this task.
Damit ist es amtlich. Das Board weigert sich, mir seine eigene Konfiguration herauszugeben, weil ich keine Lizenz gekauft habe. Nicht ändern, wohlgemerkt. Nur lesen. Der dritte Weg über die UEFI-Variablen im Kernel führt auch nicht weiter, da liegen zwar Blobs, aber ohne die Zuordnungstabellen des Boards sind das einfach nur Bytes.
Ich verstehe das Geschäftsmodell durchaus. Wer dreihundert Server ausrollt, will die Konfiguration automatisiert verteilen, und dafür Geld zu verlangen ist legitim. Nur bin ich eben nicht dreihundert Server, ich bin ein Schreibtisch, und das Auslesen dessen was ich selbst eingestellt habe fühlt sich nicht nach einem Premium-Feature an. Der Screencast über die Konsole ist der Weg drumherum, und ehrlich gesagt ist er für diese Serie sogar der bessere: ein Screenshot zeigt den Schalter so, wie er dir im Setup auch begegnet.
Was in der Serie kommt
Ich gehe das BIOS in Themenblöcken durch, nicht Menüseite für Menüseite. Jede Folge nimmt sich einen Bereich vor und beantwortet für die Schalter darin drei Fragen: was tut das, warum steht es bei mir so, und wann würde man es anders setzen. Grob in dieser Reihenfolge:
Boot Feature und Power, also alles rund um Einschalten, Watchdog und das Verhalten nach einem Stromausfall
Die CPU, einmal die Seite mit Kernen, Threads und Prefetchern, einmal die mit AES-NI, TME, SGX und den Sicherheitsfunktionen
Speicher, ECC, Patrol Scrub und die Frage warum mein DDR4-3200 mit 2933 läuft
NUMA und der Uncore-Bereich, der auf einer Single-Socket-Maschine erstaunlich viel Unsinn anzeigt
PCIe in drei Folgen: Slots und Lanes, dann Adressraum und Resizable BAR, dann die Fehlerbehandlung, wo es richtig spannend wird
Virtualisierung mit VT-d, IOMMU und VMD
Storage, Preboot-Netzwerk, Bootreihenfolge und Secure Boot
Zum Schluss der BMC selbst, das TPM und die Ereignisprotokolle
Die Folgen sind bewusst kurz gehalten. Ein Thema, einmal sauber durch, und nicht ein Wälzer den keiner zu Ende liest.
Ein Wort dazu, warum ich das überhaupt aufschreibe
Zwei Gründe. Der erste ist eigennützig: indem ich es aufschreibe, muss ich es verstanden haben. Ein Halbwissen überlebt keinen Absatz, in dem man erklären soll warum etwas so ist. Das ist der beste Lerneffekt den ich kenne.
Der zweite Grund ist etwas nachdenklicher. Dieses Wissen ist am Aussterben, und das meine ich ohne Larmoyanz. Wer heute Rechenleistung braucht, klickt sie in einer Cloud zusammen, und dort gibt es kein BIOS mehr, an das man herankäme. Wer eine Frage hat, fragt eine AI und bekommt eine Antwort, die meistens stimmt. Beides ist bequem und für die allermeisten Zwecke völlig ausreichend. Trotzdem muss es am Ende weiterhin Menschen geben, die wissen was unter der Abstraktion passiert. Wenn eine Maschine nicht bootet, hilft kein Terraform.
Und damit zum wichtigsten Teil: wenn ich mich irre, sag es mir bitte. Ich schreibe diese Serie auch, um dazuzulernen, und eine Korrektur ist dafür der schnellste Weg. Ich trage sie dann im jeweiligen Beitrag nach, mit Datum, und lasse sie nicht stillschweigend verschwinden. Gerade bei den Prozessor-Interna und beim PCIe-Teil wird es hier Leute geben, die tiefer drin stecken als ich.
Nächste Folge: Boot Feature und Power. Also die Frage, warum mein Rechner nach einem Stromausfall absichtlich aus bleibt, und was ein Interrupt aus dem Jahr 1981 heute noch in einem UEFI-Setup zu suchen hat.
Habt ihr auf eurem Board schon mal versucht, die BIOS-Konfiguration maschinell auszulesen, und seid dabei auch gegen eine Lizenz gelaufen? Erzählt es mir gerne, ihr dürft mich jederzeit fragen.
Am 16. März 2026 hat Bing meine Seite ausgeliefert wie an jedem Tag davor. Am 17. März nicht mehr. Nicht schlechter platziert, nicht seltener, sondern praktisch gar nicht. Sieben Tage lang kein einziger Klick. Und es blieb nicht bei der Websuche: die Zitate in Copilot hörten am selben Tag auf. Knapp vier Monate später, am 9. Juli, ging es genauso abrupt wieder los.
Bevor jetzt jemand Mitleid entwickelt: Bing macht bei diesem Blog rund ein Prozent des Traffics. Der Ausfall hat mich nichts gekostet, aufgefallen ist er mir erst Wochen später, und wäre er nie repariert worden, wäre das hier trotzdem kein trauriger Beitrag geworden. Interessant ist er aus einem anderen Grund.
So ein sauberer binärer Ausfall ist selten zu sehen. Kein Abrutschen über Wochen, kein Rauschen, sondern über Nacht zu und Monate später über Nacht wieder auf. Und das Beste: man kann es selbst rekonstruieren. Die Exporte aus den Webmaster Tools plus das eigene Access-Log reichen aus, um den Zeitpunkt auf den Tag genau zu datieren und eine sehr wahrscheinliche Ursache einzukreisen. Ohne dass der Anbieter je erklärt hätte, was er eigentlich getan hat.
Was jetzt kommt, ist also keine Beschwerde, sondern eine Fehleranalyse an einem Fall, bei dem der Schaden bei null lag und die Datenlage ungewöhnlich gut ist.
Zwei Kanten, dazwischen vier Monate
Absolute Zahlen zu Besuchern veröffentliche ich hier nicht, die braucht die Geschichte auch nicht. Erzählt wird ein Verlauf, kein Niveau. Alles Folgende ist deshalb indexiert: der Durchschnitt der Impressions pro Tag im Zeitraum 01.11.2025 bis 16.03.2026 ist die Baseline und bekommt den Wert 100. Impressions sind dabei die Anzahl der Ausspielungen in den Ergebnislisten, Klicks das, was danach kommt. Ich halte die beiden konsequent auseinander, weil genau in ihrem Verhältnis der interessanteste Teil steckt.
Monat
Index
Kommentar
2025-08
81
normaler, langsam wachsender Verlauf
2025-09
88
2025-10
104
2025-11
107
2025-12
108
Höchststand
2026-01
105
2026-02
91
2026-03
44
Einbruch am 17.03. mitten im Monat
2026-04
24
Teilerholung auf niedrigem Niveau
2026-05
10
zweiter Einbruch ab 16.05.
2026-06
7
Tiefpunkt
2026-07
55
am 09.07. fällt der Schalter wieder um
2026-08 (5 Tage)
74
Die Monatswerte glätten beide Kanten weg, weil sie mitten im Monat liegen. Nach Phasen sortiert wird es deutlicher. Gleiche Baseline, dazu die Klicks und die Klickrate:
Phase
Impressions
Klicks
CTR
Baseline 01.11.2025 bis 16.03.2026 (136 Tage)
100
100
2,75 %
17.03. bis 23.03.2026 (7 Tage)
0,6
0
0 %
24.03. bis 15.05.2026 (53 Tage)
21
41
5,3 %
16.05. bis 08.07.2026 (54 Tage)
6
12
5,2 %
09.07. bis 05.08.2026 (28 Tage)
69
95
3,75 %
Dieselben Zahlen als Bild. Beide Kanten sind senkrecht, dazwischen liegen vier Monate. Die Klicks (gestrichelt) kommen fast vollständig zurück, die Impressions nicht.
Die erste Kante liegt zwischen dem 16. und dem 17. März: von normalem Niveau auf 0,6 Prozent der Baseline. Das ist kein Einbruch mehr, das ist aus. Eine Woche lang blieb es dabei, ohne einen einzigen Klick.
Die zweite Kante liegt zwischen dem 8. und dem 12. Juli: innerhalb von vier Tagen mehr als das Sechsfache. Der Anstieg beginnt am 9. Juli, also an genau dem Tag, an dem die Lösungs-Mail aus dem Support eintraf. Dazu später mehr, das Detail ist hübscher, als es klingt.
Das sieht nicht aus wie ein Ranking
Das ist die zentrale Beobachtung, und sie steckt in der Form der Kurve, nicht in ihrer Höhe. Rankings rutschen. Wenn eine Seite an Gewicht verliert, fällt sie über Wochen durch die Positionen, einzelne Suchbegriffe halten sich länger als andere, das Ganze franst aus. Was hier passiert ist, franst nirgends aus. Es ist an einem Tag da und am nächsten weg, und vier Monate später umgekehrt.
Zu einem Filter auf Domain-Ebene passt so ein Muster ausgesprochen gut. Ich schreibe bewusst passt zu und nicht beweist, denn die Form einer Kurve ist ein Indiz und keine Diagnose. Aber sie legt fest, wonach man überhaupt suchen muss: nach etwas, das sich ein- und ausschalten lässt, und nicht nach etwas, das langsam an Gewicht verliert.
Dazu kommt ein zweiter Befund, der in dieselbe Richtung zeigt und den ich für den stärkeren halte. Die Webmaster Tools weisen die Zitate in Copilot getrennt von der Websuche aus, es sind zwei verschiedene Oberflächen mit unterschiedlichen Auswahlmechanismen. Beide gingen am 17. März taggleich auf null. Dass eine Seite zeitgleich aus der Ergebnisliste und aus den KI-Antworten verschwindet, passt schlecht zu einer Bewertung einzelner Seiten und gut zu einem gemeinsamen Gate, das eine Ebene darüber sitzt und die ganze Domain betrifft. Ein Ranking-Effekt müsste sich erst durch die eine Oberfläche arbeiten und dann durch die andere, und ganz sicher nicht innerhalb eines Tages.
Ein Nebenbefund aus der Phasentabelle, der mir gut gefällt: die Klickrate war in den Ausfallphasen am höchsten. 5,3 und 5,2 Prozent gegen 2,75 Prozent in der Baseline. Das klingt zunächst nach einer guten Nachricht und ist das exakte Gegenteil. Wenn fast nichts mehr ausgeliefert wird, überleben nur die allerspezifischsten Suchanfragen, und die klicken naturgemäß gut. Eine steigende CTR ist eben nur dann ein gutes Zeichen, wenn der Nenner stabil bleibt.
Was es alles nicht war
Bevor man eine Ursache benennt, sollte man die naheliegenden Kandidaten abräumen. Das ist der Teil, der aus einer Vermutung eine Untersuchung macht.
Kein Crawling-Problem. Der Bingbot kam die ganze Zeit unverändert vorbei, rund 80 Anfragen pro Tag, ganz überwiegend mit Status 200. Das steht im eigenen Access-Log und ist damit die eine Quelle, der ich in diesem Fall vollständig traue. Gecrawlt wurde also durchgehend. Ausgeliefert wurde nur nichts.
Spricht klar gegen eine Löschung aus dem Index. DuckDuckGo und Ecosia beziehen ihre Ergebnisse von Bing und lieferten während der ganzen Zeit weiterhin Treffer zu meiner Domain. Bing selbst zeigte also weniger als seine eigenen Syndication-Partner. Die Inhalte waren demnach nach wie vor im Index, nur bing.com hat sie nicht mehr herausgegeben. Das ist für mich das hübscheste Detail des ganzen Falls und spricht stark für ein Gating auf der Ausliefer- oder Vertrauensebene statt für einen Deindex.
Spricht gegen ein reines Nutzersignal-Problem. Wenn eine Seite wegen schlechter Klickraten oder Nutzerverhalten abgewertet wird, erwartet man, dass die Klicks wegbrechen. Hier verschwanden die Impressions. Ich will das Argument nicht überdehnen: ein hinreichend starkes Vertrauens- oder Demotionssignal kann durchaus auch Impressions kosten. Es schwächt die Nutzersignal-Hypothese, es räumt sie nicht ab.
Nichts an Technik gefunden. Canonicals sauber, Sitemap in den Webmaster Tools als Success verarbeitet, am Server wurde im relevanten Zeitraum nichts umgestellt. Das ist ausdrücklich ein „nichts gefunden“ und kein „ausgeschlossen“, aber die üblichen Verdächtigen sind es damit nicht.
Keine Inhalts- oder Risikoklassifikation. Eigener Test mit SafeSearch von streng bis aus: kein Unterschied. Wer hier mit Erwachsenenfiltern hantiert, wird an diesem Blog wenig Freude haben, aber prüfen kostet nichts.
Keine Link-Armut. Die Google Search Console zeigte im selben Zeitraum ein diverses, redaktionelles deutsches Linkprofil aus Fachforen, Fachblogs und Projektseiten. Es fehlten keine Links. Das Gate hat die Domain trotz vorhandener Verweise ignoriert.
Und dann ist da noch die Kontrollgruppe, die einem hier die halbe Arbeit abnimmt: Google lief die ganze Zeit unauffällig weiter. Google liefert bei diesem Blog ohnehin um Größenordnungen mehr Suchklicks als Bing, ich hätte einen Einbruch dort also sofort gesehen. Gleiche Inhalte, gleicher Server, gleiche Technik, gleiche Links, ein Anbieter ganz normal, der andere komplett zu. An der Seite selbst kann es damit kaum gelegen haben. Für dieses Argument braucht es keine einzige Zahl, und das ist auch gut so.
Der Fund: eine Spalte namens Source
Die Webmaster Tools bieten einen Export mit dem Namen IndexNow Submitted URLs. Ich habe ihn lange für eine Fleißliste gehalten und nie hineingeschaut. Er hat eine Spalte, die den ganzen Fall aufgeschlossen hat, und die heißt Source. Sie unterscheidet, wer eine URL eingereicht hat:
wordpress steht für das WordPress-Plugin, das ereignisgesteuert meldet, wenn ein Beitrag veröffentlicht oder aktualisiert wird.
none steht für einen direkten Aufruf der HTTP-Schnittstelle, also von Hand oder per Skript.
Der Export umfasst 339 Einreichungen zwischen dem 04.01.2025 und dem 06.08.2026. Davon tragen 121 die Quelle wordpress und 218 die Quelle none. Und jetzt kommt der Teil, bei dem ich kurz still geworden bin: alle 218 manuellen Einreichungen liegen in einem einzigen Fenster, nämlich zwischen dem 11.02.2026 und dem 18.03.2026. Davor ausschließlich das Plugin. Danach ausschließlich das Plugin. Das Fenster ist scharf abgegrenzt, ohne eine einzige Ausnahme.
Datum
Einreichungen
Zeitfenster (CEST)
11.02.2026
93
11:35 bis 11:55
21.02.2026
1
09.03.2026
3
11.03.2026
6
12.03.2026
10
13.03.2026
3
14.03.2026
98
09:33 bis 15:00
18.03.2026
4
93 URLs in zwanzig Minuten. 98 URLs an einem einzigen Tag. 214 manuelle Einreichungen in 32 Tagen. Bei einem Blog mit rund 460 Beiträgen habe ich in fünf Wochen also grob die halbe Seite per Schnittstelle angeklopft. Und zwar durchweg alte, unveränderte Beiträge, an denen sich nichts getan hatte, außer dass ich sie gerne mal wieder frisch gecrawlt gesehen hätte.
Der Einbruch kam am 17.03.2026, also drei Tage nach dem zweiten Massen-Batch.
Was IndexNow ist und wofür ich es gehalten habe
IndexNow ist ein bewusst simpel gehaltenes Push-Protokoll. Statt darauf zu warten, dass irgendwann ein Crawler vorbeischaut, sagt die Seite der Suchmaschine aktiv Bescheid: diese URL hat sich geändert, schau noch mal rein. Technisch ist das ein Aufruf mit einer URL und einem Schlüssel, der als Textdatei im Web-Root liegt und die Domain ausweist. Bing, Yandex, Seznam, Naver und Yep hängen am selben Pool, eine Meldung erreicht also alle. Gedacht ist das Ganze als Ereignis-Signal: eine Änderung, eine Meldung.
Und genau hier liegt der Denkfehler, den ich rückblickend ziemlich sauber benennen kann. Das Ding sieht aus wie ein technischer Endpunkt zur Cache-Invalidierung. So habe ich es auch behandelt: Liste durchgehen, Schnittstelle anstoßen, fertig. Praktisch ist es aber ein Vertrauenskanal. Jede einzelne Einreichung trägt die implizite Behauptung „hier hat sich etwas geändert, das einen erneuten Besuch wert ist“. Zweihundert solcher Behauptungen für unveränderte Altbeiträge innerhalb von fünf Wochen sind aus Sicht des empfangenden Systems kein zu häufig aufgerufener Endpunkt, sondern ein Häufungsmuster, das nach Missbrauch aussieht. Völlig unabhängig davon, was ich mir dabei gedacht habe.
Dass das keine reine Rückschau-Weisheit ist, steht sogar in der Spezifikation. Die Liste der Antwortcodes kennt neben den üblichen Verdächtigen einen 429 Too Many Requests, und dahinter steht wörtlich potential Spam. Das Protokoll selbst denkt an dieser Stelle also bereits über Missbrauch nach, nicht über Serverlast. Nur: eine konkrete Obergrenze nennt die Dokumentation nirgends. Es gibt keine Zahl, gegen die man sich prüfen könnte, und einen 429 habe ich nie gesehen. Das Limit ist unsichtbar, bis man darüber ist, und dann meldet sich niemand.
Ein Detail passt hier noch gut hinein, auch wenn es die Kausalität nicht beweist: Google nimmt an IndexNow gar nicht teil. Der Kanal, über den ich hier zweihundertmal angeklopft habe, existiert bei dem einen Anbieter, der ausgefallen ist, und bei dem anderen, der unbeeindruckt weiterlief, schlicht nicht.
Wo die Rekonstruktion aufhört
Das muss klar dastehen, sonst wird aus einer ordentlichen Analyse eine Behauptung: Das ist eine sehr gut belegte Korrelation und kein Beweis. Microsoft hat die Ursache nie schriftlich benannt. Die spätere Lösungs-Mail verweist pauschal auf den Abschnitt Things to Avoid der Webmaster Guidelines und sonst auf nichts.
Was ich habe, ist zeitliche Nähe, ein scharf abgegrenztes Fenster, das exakt vor dem Ausfall endet, und keinen anderen plausiblen Auslöser im selben Zeitraum. Das ist die beste verfügbare Erklärung. Es ist nicht die bestätigte. Mit dieser Unterscheidung kann ich gut leben, sie macht den Fall nicht schwächer.
Offen bleibt auch der zweite Einbruch. Ab dem 16.05.2026 fiel der Wert noch einmal von Index 21 auf 6, und in diesem Zeitraum gab es überhaupt keine manuellen Einreichungen mehr. Plausibel wäre eine erneute automatische Bewertung, belegen kann ich das nicht. Das bleibt so stehen, weil Wegerklären hier nichts besser machen würde.
Der Support, oder: ein Lautsprecher ist keine Mailbox
Am 06.05.2026 habe ich ein Ticket aufgemacht, Nummer REQ00222409, Betreff sinngemäß „My site suddenly dropped in ranking“. Die automatische Bestätigung kam sofort und war erfreulich konkret: das Ticket werde einem Global Support Webmaster Engineer zugewiesen, eine Rückmeldung sei in 10 days zu erwarten.
Am 19.05.2026 habe ich nachgefragt, ganz konventionell per Antwort auf ebendiese Bestätigungsmail. Das Ergebnis war ein harter Bounce:
Der Fehler ist immerhin eindeutig. Die Absenderadresse ist eine interne Microsoft-365-Verteilergruppe, die ausschließlich authentifizierte Sender aus dem eigenen Tenant annimmt. Der Kanal, über den mein Ticket bestätigt wurde, kann also grundsätzlich keine Antwort empfangen. Das ist keine Mailbox, das ist ein Lautsprecher.
Parallel dazu war die Support-Oberfläche in den Webmaster Tools für diese Property nie freigeschaltet, dort stand durchgehend „No pages found“. Es gab damit buchstäblich keinen Rückkanal. Danach passierte 64 Tage lang nichts, und ich habe das Ticket innerlich abgehakt.
Die Mail, der ich erst nach dem Header-Check geglaubt habe
Am 09.07.2026 um 21:23 Uhr CEST kam dann doch noch eine Mail auf demselben Ticket. Der Kern: the issue related to your site … has been resolved, die Erholung könne up to 2-3 weeks dauern, das Ticket werde geschlossen, Rückfragen bitte über ein Feedback-Formular. Der Einweg-Kanal kann also durchaus zustellen. Nur empfangen kann er nichts.
Eine Mail, die nach zwei Monaten Funkstille auftaucht und etwas Erfreuliches behauptet, ist erst mal eine Mail und keine Tatsache. Also habe ich die Header gelesen, bevor ich ihr geglaubt habe. DKIM, SPF und DMARC standen alle auf pass, die Absenderdomain fährt eine DMARC-Policy auf reject, und meine eigene Spam-Filterung hat die Nachricht mit deutlich negativer Bewertung und no action durchgewinkt. Also echt, kein Spoofing. In einem Blog, in dem ziemlich viel über Mail-Härtung steht, ist so ein Blick kein Sonderaufwand, sondern Reflex.
Und dann steht in derselben Mail noch eine Empfehlung: zur Beschleunigung des erneuten Crawlens möge ich doch IndexNow nutzen. Also genau das Werkzeug, dessen massenhafte Verwendung die beste verfügbare Erklärung für den ganzen Vorfall ist. Ich habe den Rat nicht befolgt. Das WordPress-Plugin macht seinen ereignisgesteuerten Job, und das reicht.
Was zurückkam und was nicht
Bei den Klicks ist die Erholung praktisch vollständig, Index 95 gegen die Baseline. Bei den Impressions liegt sie bei rund zwei Dritteln bis drei Vierteln, Index 69 über die ersten 28 Tage und 74 in den ersten Augusttagen. Die Klickrate liegt nach der Erholung mit 3,75 Prozent über dem Vorher-Wert. Ob die Impressions noch weiter steigen oder ob das jetzt der neue Normalzustand ist, weiß ich schlicht nicht.
Wichtig ist mir dabei eine Sache, die man leicht falsch herum erzählen könnte: die Wiederherstellung kam serverseitig. Es gibt keinen erkennbaren Zusammenhang mit irgendeiner Maßnahme von mir. Dass das Ticket den Ausschlag gegeben hat, passt zeitlich gut, belegt ist es nicht. Ich habe in diesen vier Monaten nichts repariert, weil es an meinem Ende nichts zu reparieren gab.
Und ein Punkt ist ausdrücklich ungeklärt: ob die Copilot-Zitate wieder da sind. Hier lassen sich zwei Datenquellen leicht verwechseln, deshalb sauber getrennt. Der taggleiche Ausfall am 17. März stammt aus früheren, tagesaufgelösten Exporten, die ich im Juni ausgewertet habe, und den halte ich für belastbar. Die aktuellen Reports zur KI-Nutzung summieren dagegen über das volle 24-Monats-Fenster und lassen sich nicht auf einen Zeitraum eingrenzen. Sie enthalten also überwiegend Zitate aus der Zeit vor dem Vorfall, und aus ihnen lässt sich weder eine Erholung noch ein Ausfall ableiten. Für diese Frage sind sie schlicht unbrauchbar, auch wenn sie auf den ersten Blick beruhigend aussehen. Kurz: das Aussetzen ist datiert, die Rückkehr ist ungeprüft.
Was Microsoft konkret umgestellt hat, ist ebenfalls unbekannt und wurde nie mitgeteilt.
Was hängen bleibt
Ein automatisches „ich habe hier etwas Neues“-Signal ist kein technischer Endpunkt, sondern ein Vertrauenskanal mit einem unsichtbaren Limit. Man benutzt es so, wie es gedacht ist: ereignisgesteuert, ausgelöst von einer echten Änderung, und nicht als Batch-Werkzeug für eine Liste alter Beiträge. Das ist die eine Sache, die ich vorher nicht so gesehen habe.
Ansonsten steht der Schalter wieder auf an, niemand hat je aufgeschrieben, was er eigentlich war, und da hier ohnehin nur ein Nebenkanal dranhing, ist das auch völlig in Ordnung. Die Verteilung dieses Blogs steht auf mehreren Beinen, und Bing war nie eines der tragenden. Schulterzucken, weitermachen.
Hast du so ein Muster schon einmal an einer eigenen Domain gesehen, bei Bing oder woanders, und konntest es sauber datieren? Oder kennst du eine dokumentierte Obergrenze für IndexNow, die ich übersehen habe? Dann dürft ihr mich sehr gerne fragen.
Moin. Im letzten Beitrag ging es darum, wie ich meinen OpenPGP-Schlüssel an Domain, GitHub und Matrix gebunden habe, also um die Frage, woher eigentlich irgendwer wissen soll, dass dieser Schlüssel mir gehört. Ganz am Ende stand dort die Ankündigung eines zweiten Teils: wie das Ding überhaupt gebaut ist und wie sicher es wirklich ist. Der Teil ist jetzt hier.
Der Beitrag hat zwei Hälften, und die kannst du unabhängig voneinander lesen. Teil A ist ein Rezept. Wenn du einen Schlüssel nach demselben Muster bauen willst, arbeitest du dich von oben nach unten durch, inklusive der kompletten GnuPG-Konfiguration, alles kopierbar und in der richtigen Reihenfolge. Teil B erklärt, was die einzelnen Bauteile bedeuten, und endet mit einer ehrlichen Einschätzung, wie sicher das alles am Ende ist.
Der eigentliche Grund, warum ich das aufschreibe, ist aber ein anderer. Zwischen „mein Schlüssel ist modern“ und „mein Schlüssel ist richtig konfiguriert“ liegt eine Lücke, und in die bin ich mit Anlauf hineingesprungen. Der Schlüssel ist Lehrbuch: Ed25519, ein Hauptschlüssel, der ausschließlich zertifiziert, drei getrennte Unterschlüssel, fünf Jahre Laufzeit, über sieben Kanäle veröffentlicht, dazu eine eID-Zertifizierung. Und trotzdem wurde er vom ersten Tag an schwächer angesprochen als der Schlüssel, den er ablöst.
Aufgefallen wäre das niemandem, und genau das ist der interessante Teil. Das einzige Symptom war eine Warnzeile auf dem Terminal von jemandem, der mir eine verschlüsselte Mail schreibt. Also auf einem Rechner, den ich nie zu Gesicht bekomme. Und die Ursache war ausgerechnet eine Härtungsmaßnahme, die ich in bester Absicht eingebaut hatte, um einen musealen Algorithmus loszuwerden. Härtung, die etwas leise verschlechtert, finde ich als Geschichte deutlich spannender als noch eine Schlüsselerzeugungsanleitung. Nachbauen kannst du das übrigens in zwei Minuten, das Labor dazu steht weiter unten.
Der Schlüssel, um den es geht
Bestandteil
Wert
Hauptschlüssel
ed25519 0x893DE0CDDE986DEB, Verwendung [C], also nur zertifizieren
Fingerabdruck
45FC D081 ADB5 4872 EA5B 06B9 893D E0CD DE98 6DEB
Signatur-Unterschlüssel
ed25519 0xD788641D8588A674
Verschlüsselungs-Unterschlüssel
cv25519 0x429D03637892821A
Authentisierungs-Unterschlüssel
ed25519 0x22F2E3234664DDBE
UIDs
Sebastian van de Meer, dazu eine Foto-UID mit 10851 Byte
Gültigkeit
erzeugt am 24.07.2026, läuft am 23.07.2031 ab
Vorgänger
ed25519 0x5F279C362EEAB216, gültig bis 31.12.2026, zeichnet den neuen gegen
Fremdzertifizierung
Governikus/eID 0x5E5CCCB4A4BF43D7, Level 3
Umgebung
GnuPG 2.4.4, libgcrypt 1.10.3
Die Aufgabenteilung steht nicht nur in der Doku, die steht maschinenlesbar im Schlüssel selbst. Jede Bindungssignatur trägt ein Feld mit Schlüssel-Flags, und das sieht bei mir so aus:
Teil A ist der Ablauf, den ich für richtig halte, und bis auf die markierten Stellen genau der, den ich gegangen bin. Dort, wo der Befehl unten besser ist als das, was ich damals getippt habe, steht ein Hinweis dazu.
GnuPG 2.4.x ist die Annahme für den ganzen Beitrag. Zwei Dinge gleich vorweg, weil sie sonst später wehtun:
--quick-generate-keyverweigert den Dienst, wenn die UID bereits auf einem anderen Schlüssel im Schlüsselbund existiert. Genau deshalb ist dieser Schlüssel über eine Parameterdatei mit --batch --gen-key entstanden. Das trifft dich zuverlässig genau dann, wenn du einen Schlüssel rotierst, also in dem Moment, in dem der alte noch da ist.
Nichts hier braucht eine Smartcard, und nichts hier braucht root.
Wenn du bei GPG ganz am Anfang stehst, sind die Grundlagen zum Signieren und Verschlüsseln hier im Blog der bessere Einstieg. Dieser Beitrag setzt voraus, dass du weißt, was ein öffentlicher Schlüssel und ein Unterschlüssel sind.
Erst aufräumen, dann anfangen
Und zwar wirklich vorher, nicht hinterher. Mein ~/.gnupg war in einem Zustand, den ich hier nur ungern zugebe.
chmod 700 ~/.gnupg
find ~/.gnupg -type f -exec chmod 600 {} +
# Erst alle GnuPG-Frontends schließen und die Daemons beenden, sonst löschst du
# unter Umständen echte, aktive Locks:
gpgconf --kill all
# Übriggebliebene Lock-Dateien abgestürzter Läufe. Bei mir waren es 93 Stück.
find ~/.gnupg -name '.#lk*' -delete
# Altlasten, die keine 2.4er Installation mehr braucht
mkdir -p ~/gnupg-attic-$(date +%F)
mv ~/.gnupg/{cert8.db,key3.db,secmod.db} ~/gnupg-attic-$(date +%F)/ 2>/dev/null
Ein ~/.gnupg mit 775 und Dateien mit 664 ist erschreckend verbreitet, und ich will das gar nicht größer machen als es ist: Auf einem Einzelplatzrechner ist das keine Katastrophe. Es wird aber genau in dem Moment eine, in dem das Home-Verzeichnis in ein Backup, in einen Container-Mount oder in einen synchronisierten Ordner wandert. Und das passiert schneller, als man denkt.
Die gpg.conf, komplett
Das ist die tatsächlich laufende Konfiguration, so wie sie bei mir liegt, mit ihren Originalkommentaren. Der Block mit dem Warnhinweis ist die wichtigste Stelle im gesamten Beitrag, warum, steht in Teil B.
# ---- UX / output ----
keyid-format 0xlong
with-fingerprint
with-subkey-fingerprint
utf8-strings
# ---- Key discovery (local first, then WKD/DANE; email-validated keyserver last) ----
auto-key-retrieve
auto-key-locate local,wkd,dane,keyserver
keyserver hkps://keys.openpgp.org
# ---- Strong defaults ----
# cipher-algo / digest-algo are intentionally NOT forced: forcing them would override
# the recipient's stated capabilities. personal-*-preferences below select strong
# algorithms interoperably instead.
cert-digest-algo SHA512
# ---- KDF hardening for passphrase-derived symmetric keys (gpg -c / key export) ----
s2k-mode 3
s2k-digest-algo SHA512
s2k-cipher-algo AES256
s2k-count 65011712
# ---- WARNING. Legacy ciphers: do NOT disable 3DES here ----
# An earlier version of this file carried:
# disable-cipher-algo 3DES
# disable-cipher-algo IDEA
# disable-cipher-algo CAST5
# disable-cipher-algo BLOWFISH
# disable-cipher-algo TWOFISH
# The 3DES line alone silently strips ALL algorithm preferences from every key you
# generate while it is active, and it also blocks DECRYPTION of old archives.
# New encryption never selects a legacy cipher anyway, because
# personal-cipher-preferences lists AES only.
# ---- Privacy / minimal metadata ----
no-comments
no-emit-version
export-options export-minimal
# ---- Listing/verification quality-of-life ----
verify-options show-uid-validity
list-options show-uid-validity
# ---- Trust model ----
trust-model tofu+pgp
# ---- Your key ----
default-key 0x45FCD081ADB54872EA5B06B9893DE0CDDE986DEB
# hidden-encrypt-to 0x45FCD081ADB54872EA5B06B9893DE0CDDE986DEB # optional, off
# ---- Local policy: what *you* prefer when sending ----
personal-cipher-preferences AES256 AES192 AES
personal-digest-preferences SHA512 SHA384 SHA256
weak-digest SHA1
force-ocb
# ---- Preferences baked into keys you create from here on ----
default-preference-list SHA512 SHA384 SHA256 AES256 AES192 AES ZLIB BZIP2 ZIP Uncompressed
Zwei Zeilen darin sind Entscheidungen und keine Selbstverständlichkeiten, deshalb je ein Satz dazu.
force-ocb ist keine Präferenz, sondern eine Erzwingung. Es sorgt dafür, dass ausgehende Nachrichten die OCB-Variante aus der LibrePGP-Linie benutzen, und setzt sich dabei über das hinweg, was der Empfängerschlüssel ankündigt. Sehr alte oder anders implementierte Empfänger können solche Nachrichten nicht lesen. Das steht in einer gewissen Spannung zu dem Prinzip ein paar Zeilen weiter oben, wo ich cipher-algo bewusst nicht erzwinge. Für mich ist das in Ordnung, in einer Konfiguration zum Abschreiben würde ich die Zeile weglassen.
auto-key-retrieve holt unbekannte Schlüssel beim Prüfen automatisch nach. Sehr bequem, und es verrät dem Keyserver, welche signierten Nachrichten du wann liest. Für mich ein akzeptabler Tausch, bei einem anderen Bedrohungsmodell schaltest du das besser ab.
Dazu die beiden kleinen Geschwisterdateien. gpg-agent.conf:
Ein Hauptschlüssel, der nur zertifiziert, über eine Parameterdatei. Die Zeile Preferences: war in meinem echten Lauf nicht drin, nimm sie mit. Sie kostet nichts und fängt genau den Fehler ab, um den es in Teil B geht. Das ist keine Vermutung, ich habe es nachgemessen: Mit dieser Zeile bleiben die Präferenzen selbst dann im Schlüssel, wenn die defekte Konfiguration aktiv ist. Die eigentliche Kontrolle bleibt trotzdem der Paket-Dump gleich darunter, denn eine Parameterdatei ersetzt nie die Prüfung des fertigen Artefakts.
cat > keyparams.txt <<'EOF'
Key-Type: eddsa
Key-Curve: Ed25519
Key-Usage: cert
Name-Real: Sebastian van de Meer
Name-Email: kernel-error@kernel-error.com
Expire-Date: 5y
Preferences: AES256 AES192 AES SHA512 SHA384 SHA256 ZLIB BZIP2 ZIP Uncompressed
%ask-passphrase
%commit
EOF
gpg --batch --gen-key keyparams.txt
shred -u keyparams.txt
Und jetzt sofort nachsehen, ob die Präferenzen auch wirklich im Schlüssel gelandet sind. Genau diese Prüfung fehlte bei mir im Juli, und deshalb steht sie hier direkt hinter der Erzeugung und nicht irgendwo weiter unten.
Du willst dort pref-sym-algos, pref-hash-algos, pref-zip-algos und ein features mit gesetztem Bit 0x01 sehen. Wenn dort nur eine features-Zeile steht und sonst nichts, dann hör hier auf und lies erst den Abschnitt über den Defekt in Teil B. Dann stimmt etwas mit deiner Konfiguration nicht, und der Schlüssel trägt den Fehler ab sofort dauerhaft mit sich herum.
Zum shred oben noch ein Wort, weil es sonst falsche Sicherheit erzeugt: Es entfernt die sichtbare Arbeitsdatei, mehr nicht. Auf SSDs, auf ZFS, bei Snapshots und auf journalenden Dateisystemen ist damit überhaupt nicht garantiert, dass keine alten Blöcke mehr herumliegen. Solche Zwischendateien sollten deshalb von vornherein nur auf einem verschlüsselten Dateisystem entstehen.
Die drei Unterschlüssel
Interaktives --quick-add-key ist mir in einer nicht-interaktiven Shell mit einem /dev/tty-Fehler um die Ohren geflogen. Mit --batch läuft es durch.
240×288 Pixel, Graustufen-JPEG, 10851 Byte. Halt das Bild klein, es reist in jedem vollständigen Export mit und ist der mit Abstand größte Posten in der Schlüsselgröße.
gpg --edit-key $FPR
> addphoto
> /pfad/zu/sebastian-photo-uid.jpg
> save
Eine Sache dazu, die man vorher wissen sollte: Keiner der drei Keyserver, die ich benutze, hat die Foto-UID nach dem Upload je wieder herausgerückt. Für jeden Keyserver da draußen lege ich die Hand nicht ins Feuer, für die drei, auf die es ankommt, schon. Das Bild überlebt damit nur auf den Kanälen, die ich selbst hoste. Damit ist die Foto-UID Dekoration und keine Funktion. Das ist ein völlig legitimer Grund, sie trotzdem mitzunehmen, man sollte sich nur nichts vormachen.
Backup und Widerrufszertifikat, bevor irgendetwas veröffentlicht wird
Zwei Dinge müssen existieren, bevor der Schlüssel irgendwo landet: ein Backup des geheimen Schlüssels und ein Widerrufszertifikat. Beides später nachzuholen ist der Klassiker, den man genau einmal bereut.
Ein Widerrufszertifikat legt GnuPG bei der Erzeugung ohnehin schon selbst unter ~/.gnupg/openpgp-revocs.d/<FPR>.rev ab. Das Backup dagegen ist erst dann eines, wenn du es einmal zurückgespielt hast. Also rein damit in ein Wegwerf-GNUPGHOME und nachsehen, ob der geheime Hauptschlüssel dort auch wirklich auftaucht und nicht bloß die Datei lesbar war. Danach wandern beide auf Offline-Medien.
Wichtig, weil es ein sehr verbreitetes Missverständnis ist: Der s2k-*-Block aus der gpg.conf betrifft den passphrasenbasierten Schutz, also etwa gpg -c und den Export. Er sagt nichts darüber aus, wie der geheime Schlüssel in ~/.gnupg auf der Platte geschützt ist. Darum kümmert sich der gpg-agent mit eigenen Parametern, und bereits vorhandenes Schlüsselmaterial wird durch eine geänderte gpg.conf nicht rückwirkend neu verpackt. Wer die beiden verwechselt, glaubt an eine Härtung, die an dieser Stelle gar nicht wirkt. Was tatsächlich in deinem Backup steht, siehst du wie immer am Artefakt selbst, per gpg --list-packets secret-key-backup.asc.
Den Hauptschlüssel aus dem Alltag nehmen
Jetzt kommt der Schritt, der die ganze Aufteilung von oben überhaupt erst einlöst. Der Hauptschlüssel hat im Alltag nichts verloren. Gebraucht wird er nur, wenn ein Unterschlüssel verlängert, ersetzt oder widerrufen wird, wenn eine UID dazukommt oder wenn du einen fremden Schlüssel zertifizierst. Also fliegt sein geheimer Teil vom Arbeitsrechner herunter, und zwar genau jetzt, nachdem Backup und Widerrufszertifikat existieren und nicht vorher.
# nur die Unterschlüssel exportieren, ohne den geheimen Hauptschlüssel
gpg --export-secret-subkeys --armor $FPR > subkeys.asc
# das gesamte geheime Material aus dem Alltags-Keyring werfen
gpg --delete-secret-keys $FPR
# und danach ausschließlich die Unterschlüssel zurückholen
gpg --import subkeys.asc
shred -u subkeys.asc
Das Doppelkreuz hinter sec ist der ganze Punkt. Es bedeutet: GnuPG kennt den Hauptschlüssel, hat sein geheimes Gegenstück aber nicht mehr. Signieren, Entschlüsseln und Authentisieren laufen unverändert weiter, dafür sind die Unterschlüssel zuständig. Was nicht mehr geht, ist alles, was die Identität selbst betrifft, also neue Unterschlüssel anlegen, Laufzeiten verlängern, UIDs ergänzen und fremde Schlüssel zertifizieren.
Der geheime Hauptschlüssel liegt ab hier zusammen mit dem Widerrufszertifikat auf verschlüsseltem Wechselmedium. Wenn ich ihn brauche, kommt er ausdrücklich nicht zurück nach ~/.gnupg, sondern in ein temporäres GNUPGHOME, das hinterher wieder verschwindet.
Ein Detail dabei ist wichtig genug für einen eigenen Absatz, weil es sonst still danebengeht: Das Offline-Backup ist ein Schnappschuss. Es entstand oben, bevor der alte Schlüssel gegengezeichnet hat und bevor die Governikus-Zertifizierung da war. Diese Fremdsignaturen hängen am öffentlichen Teil, der im Alltags-Keyring liegt, nicht im Backup. Wer nur das Backup einspielt und dort arbeitet, exportiert hinterher einen Schlüssel, dem genau diese Signaturen fehlen. Also immer beides einspielen:
# aktuellen öffentlichen Stand samt Fremdsignaturen aus dem Alltags-Keyring mitnehmen
gpg --armor --export-options no-export-minimal --export $FPR > /tmp/pub-aktuell.asc
export GNUPGHOME=$(mktemp -d); chmod 700 "$GNUPGHOME"
trap 'rm -rf "$GNUPGHOME"' EXIT HUP INT TERM # auch bei Abbruch aufräumen
gpg --import /media/offline/secret-key-backup.asc
gpg --import /tmp/pub-aktuell.asc
# hier die Arbeit am Schlüssel, etwa eine Laufzeit verlängern
gpg --armor --export-options no-export-minimal --export $FPR > /tmp/pub-neu.asc
rm -rf "$GNUPGHOME"; unset GNUPGHOME
Danach importierst du die aktualisierte öffentliche Hälfte in den Alltags-Keyring und rollst sie über alle Veröffentlichungskanäle aus. Das ist unbequem. Es soll unbequem sein, denn genau diese Unbequemlichkeit ist der Grund, warum der Hauptschlüssel selten angefasst wird und deshalb schwer zu verlieren ist.
Den alten Schlüssel den neuen gegenzeichnen lassen
Ein weicher Übergang statt einer harten Kante: Der alte Schlüssel bleibt bis zu seinem Ablauf gültig und zertifiziert den neuen. Wer dem alten Schlüssel schon vertraut, bekommt damit einen kryptografischen Pfad zum neuen, ohne mich irgendwo anrufen zu müssen.
Das ist ein eigenes Thema, deshalb hier nur zusammengefasst: Governikus ist nach meinem Stand vom August 2026 der einzige mir bekannte noch aktive Dienst in Deutschland, der OpenPGP-Schlüssel über die Online-Ausweisfunktion zertifiziert. Du schickst deinen öffentlichen Schlüssel hin, weist dich mit dem Personalausweis aus und bekommst eine Zertifizierung mit Level 3 zurück. Die Volksverschlüsselung fällt als Alternative aus, die macht ausschließlich S/MIME nach X.509, erzeugt die Schlüssel selbst und wird ohnehin eingestellt.
Strukturell wichtig für den Rest des Beitrags ist nur ein Punkt: Diese Zertifizierung ist eine Fremdsignatur. Und die Hälfte meiner Veröffentlichungskanäle wirft Fremdsignaturen weg. Warum, steht gleich.
Vier Exporte für vier Aufgaben
Das ist der Teil, den fast alle Anleitungen überspringen, und ausgerechnet hier fallen die echten Entscheidungen. Vier verschiedene Exporte desselben Schlüssels für vier verschiedene Jobs, mit den gemessenen Größen der Artefakte, die tatsächlich ausgeliefert werden:
Alle vier Befehle habe ich gegen die live ausgelieferten Dateien geprüft, sie reproduzieren die Artefakte byteidentisch, sha256 stimmt jeweils überein. Du kannst sie also so übernehmen.
Und jetzt die fiese Falle. In meiner gpg.conf steht global export-options export-minimal. Export-Optionen summieren sich, sie ersetzen einander nicht. --export-options export-clean hebt export-minimal also nicht auf. Du bekommst weiterhin einen minimalen Export, still und ohne Warnung, mit weggeworfenen Fremdsignaturen. Nur die ausdrückliche Verneinung funktioniert:
Das ist genau die Sorte Fehler, bei der du fest davon überzeugt bist, einen Schlüssel mit allen Signaturen veröffentlicht zu haben, während in Wahrheit die eID-Zertifizierung nie das Haus verlassen hat.
Die zweite Stolperfalle beim Export: export-minimal wirft zwar Fremdsignaturen weg, behält aber die Foto-UID. Für den DANE-Record muss auch das Bild raus, sonst wächst der Record von rund einem Kilobyte auf gute zwölf. Dieser Befehl hat den ausgelieferten Record mit 1298 Byte erzeugt:
Zwei Anmerkungen noch zu den Formaten, weil sich in der 2.4er Reihe etwas geändert hat:
--print-dane-recordsgibt es nicht mehr. Der Nachfolger heißt --export-options export-dane.
Verwechsle die beiden Ausgaben aber nicht. export-dane liefert einen fertigen DNS-Präsentationsblock, also $ORIGIN, Kommentarzeilen und generische TYPE61-Rdata, bei mir 25642 Byte. Das ist kein binäres Schlüsselmaterial. Zum Lesen ist es praktisch, der ausgelieferte Record entstand aber aus dem binären Export oben plus base64 -w0 openpgpkey.bin. Beide Wege sind gültige Zonefile-Syntax, RFC 7929 definiert die base64-Präsentationsform und RFC 3597 die generische TYPE61-Form, BIND frisst beides. Der Einzeiler passte einfach besser in meine bestehende Zone.
Wo der Schlüssel liegt, und warum genau dort
Sieben Kanäle, und jeder existiert aus einem anderen Grund. Das „warum“ ist dabei die interessantere Hälfte:
Kanal
Format
Warum dieser Kanal
keys.openpgp.org
voll hochgeladen, Server wirft Signaturen und Foto weg
der einzige Keyserver, der die Mailadresse validiert, und der, den moderne Clients abfragen
Vertrauen hängt an DNSSEC statt an einem Signaturgraphen, muss klein bleiben, es ist ein DNS-Record
WKD advanced
kein eigener Export, CNAME auf wkd.keys.openpgp.org
spiegelt keys.openpgp.org automatisch, lässt sich hier nicht selbst hosten
WKD direct (Apex)
voll binär, 13108 B
selbst gehostet, deshalb die einzige Stelle mit Foto und Fremdsignaturen
security.txt und Direktdownload
voll ASCII, 17833 B
auffindbar für Menschen und für Scanner
Zwei abgeleitete Namen tauchen dabei auf, und die werden regelmäßig falsch gebildet. Beide habe ich unabhängig nachgerechnet, sie stimmen mit dem überein, was ausgeliefert wird:
# WKD: z-base32 des SHA-1 vom lokalen Teil, ASCII-Großbuchstaben vorher klein gemacht
# sha1("kernel-error") -> z-base32 -> 3gyjbxx9xfdggpkmx5qdd793xy431w5u
gpg --with-wkd-hash -k kernel-error@kernel-error.com
# DANE (RFC 7929): SHA-256 des kanonisierten UTF-8-Lokalteils, auf 28 Oktette gekürzt,
# hex-kodiert, dann ._openpgpkey.<domain>
# 70e1c7d87e825b3aba45e2a478025ea0d91d298038436abde5a4c2d0._openpgpkey.kernel-error.com
Zwei Präzisierungen, damit hier keine Regel steht, die breiter ist als die Spezifikationen: WKD bildet ASCII-Großbuchstaben auf Kleinbuchstaben ab, das ist kein allgemeines Unicode-Lowercasing. Und DANE übernimmt diese Abbildung nicht einfach, RFC 7929 hat seine eigene Kanonisierung des lokalen Teils. Bei mir fallen beide zusammen, weil kernel-error schon reines Kleinbuchstaben-ASCII ist. Genau deshalb ist mir der Unterschied nie begegnet. Wer einen Lokalteil mit Großbuchstaben oder Nicht-ASCII hat, darf die beiden auf keinen Fall gleichsetzen.
Und weil ich schon dabei bin, mich zu blamieren: Bei einem früheren Review habe ich DANE als „fehlt“ markiert, weil ich den z-base32-Namen aus WKD gegen das DNS geprüft habe. Also den falschen Namen. DANE lief die ganze Zeit. Wenn du deinen eigenen Aufbau prüfst, prüfe bitte den Namen, den die jeweilige Spezifikation vorschreibt, und nicht den, den du gerade im Kopf hast. Wie man einen OPENPGPKEY-Record überhaupt in die Zone bekommt und wie man die Zone dafür mit BIND signiert, steht hier im Blog.
Eine Konsequenz aus der Tabelle überrascht die Leute regelmäßig, deshalb schreibe ich sie deutlich hin:
Die Governikus-Zertifizierung ist nur auf den klassischen Keyservern und auf den selbst gehosteten Kopien sichtbar. keys.openpgp.org wirft sie weg, und gpg bevorzugt die WKD-advanced-Methode, die wiederum auf keys.openpgp.org zeigt. Der Kanal, den die meisten Clients benutzen, ist also ausgerechnet der mit den wenigsten Signaturen.
Prüfen wie ein Fremder
Der letzte Schritt in Teil A, und der wichtigste: Hol dir deinen eigenen Schlüssel so, wie es jemand tut, der dich nicht kennt. Einmal pro Suchpfad, jedes Mal in einem frischen Schlüsselbund.
# den Schlüssel holen wie ein Fremder, einmal pro Lookup-Pfad
for m in dane wkd keyserver; do
GNUPGHOME=$(mktemp -d) gpg --auto-key-locate clear,$m --locate-external-keys kernel-error@kernel-error.com
done
# welche Präferenzen bewirbt der Schlüssel tatsächlich?
gpg --export $FPR | gpg --list-packets | grep -E 'pref-|features'
# welchen Cipher wählt ein Absender wirklich?
gpg --status-fd 1 -d message.gpg | grep DECRYPTION_INFO
Damit endet das Rezept. Ab hier geht es darum, was die Teile bedeuten, und um die Frage, wie sicher der Aufbau am Ende wirklich ist.
Teil B: was das alles bedeutet
Wenn du bis hierher gekommen bist, hast du ein funktionierendes Rezept. Was du noch nicht hast, ist ein Gefühl dafür, warum die Teile so und nicht anders geschnitten sind, und wo der Aufbau trotz allem nachgibt. Genau darum geht es jetzt, und zwar in dieser Reihenfolge: erst die Struktur, dann die Zahlen, dann der Fehler und am Ende die Rechnung ohne Schönfärberei.
Warum ein Hauptschlüssel, der nur zertifiziert
Der Hauptschlüssel hat genau eine Aufgabe: zu sagen, wer zu diesem Schlüssel gehört. Er signiert die UIDs und er signiert die Unterschlüssel. Er signiert nie eine Mail und entschlüsselt nie irgendetwas. Alles Operative ist delegiert:
[C] certify Identitätsanker. Wird selten benutzt. Eine Kompromittierung ist
nicht reparierbar, ein Verlust nur über das Offline-Backup.
[S] sign Signieren im Alltag
[E] encrypt Entschlüsseln im Alltag
[A] auth Authentisierung, etwa als SSH-Schlüssel
Der Gewinn: Ein kompromittierter oder ausgedienter Unterschlüssel lässt sich widerrufen und ersetzen, ohne die Identität anzufassen, ohne die gesammelten Fremdsignaturen zu verlieren und ohne den Fingerabdruck zu ändern, der an sieben Stellen veröffentlicht ist. Dass [S] und [E] getrennt sind, hat noch einen zweiten Grund: Sie haben unterschiedliche Lebenszyklen. Einen alten Signaturschlüssel zu vernichten ist harmlos. Einen alten Verschlüsselungsschlüssel zu vernichten heißt, dass du an dein Archiv nicht mehr herankommst.
Das Ganze zahlt sich allerdings nur aus, wenn du auch die Konsequenz ziehst: Der volle Nutzen dieser Aufteilung stellt sich erst ein, wenn der Hauptschlüssel offline liegt. Genau deshalb liegt er hier offline, auf verschlüsseltem Wechselmedium und zusammen mit dem Widerrufszertifikat. Er kommt nur heraus, wenn ein Unterschlüssel verlängert, ersetzt oder widerrufen werden muss, und dann in ein temporäres GNUPGHOME und nicht zurück in den Alltags-Keyring. Auf der Arbeitsmaschine liegen ausschließlich die drei Unterschlüssel. Ein Hauptschlüssel, der nur zertifiziert, aber trotzdem neben dem Mailclient herumliegt, ist am Ende nur Kosmetik.
Der [A]-Unterschlüssel existiert übrigens, ist aber bewusst nicht im gpg-agent für SSH verdrahtet, sshcontrol ist leer. Ich habe ihn erzeugt, um mir die Option offenzuhalten. Mehr ist es im Moment nicht.
Ed25519 und X25519, was die Zahlen bedeuten
Ed25519 zum Signieren und X25519, in GnuPG als cv25519 bezeichnet, zum Verschlüsseln liegen beide bei rund 128 Bit klassischem Sicherheitsniveau. Das ist vergleichbar mit RSA-3072 und deutlich oberhalb von RSA-2048.
Sie sind dramatisch kleiner und schneller. Deshalb passt der komplette Schlüssel mit drei Unterschlüsseln, Foto und drei Fremdsignaturen immer noch in 13 KB.
Curve25519 ist fehlbedienungsresistent entworfen: keine Parameter, die man falsch wählen kann, keine Invalid-Curve-Fallen, deterministische Nonces bei Ed25519. Ein großer Teil der historischen Signatur-Unfälle in der Praxis kam aus genau den Ecken, die diese Kurve wegräumt.
Ein Punkt dazu, den du im Kopf behalten solltest, weil er gleich noch wichtig wird: 128 Bit klassische Sicherheit sind die Obergrenze dieses Schlüssels. Merk dir die Zahl. Sie ist der Grund, warum die AES-Geschichte weiter unten weniger dramatisch ist, als sie zunächst klingt.
Algorithmen-Präferenzen: was sie sind und wer sie liest
Dieses Konzept muss sitzen, sonst funktioniert die Geschichte danach nicht. Vorweg ein Satz, den viele nie gehört haben: Eine OpenPGP-Nachricht ist immer hybrid. Der asymmetrische Teil, also dein Ed25519- und X25519-Material, verpackt ausschließlich einen zufällig erzeugten symmetrischen Sitzungsschlüssel. Die eigentlichen Nutzdaten verschlüsselt ein symmetrisches Verfahren, in der Regel AES. Der Fehler, um den es gleich geht, saß in dieser symmetrischen Hälfte.
Ein OpenPGP-Schlüssel trägt nämlich nicht nur öffentliche Schlüssel spazieren. Die Selbstsignatur jeder UID enthält Subpakete, die ankündigen, was der Besitzer verarbeiten kann:
Das features-Oktett ist die Stelle, an der ich mich in meinen eigenen Notizen vertan hatte, deshalb hier die Dekodierung. In der Linie, die GnuPG 2.4 implementiert, bedeuten die Bits des ersten Oktetts:
0x01 SEIPD-v1 mit Modification Detection Code (MDC)
0x02 AEAD
0x04 Unterstützung für das v5-Schlüssel- und Fingerabdruckformat
features 05 ist also 0x01 + 0x04, das heißt MDC und kein AEAD. features 07 wäre 0x01 + 0x02 + 0x04, und genau das bekommt ein Schlüssel, den GnuPG 2.4 mit Standardeinstellungen erzeugt. Der kaputte Zustand war features 04, also 0x04 ganz allein: weder MDC noch AEAD angekündigt.
Eine Versionsfußnote gehört dazu: Diese Bitbelegung und das Subpaket 34 stammen aus der LibrePGP-Linie, die GnuPG 2.4 umsetzt. RFC 9580 hat beides geändert, dort sind die AEAD-Präferenzen in Subpaket 39 umgezogen und das Feature-Modell wurde umgebaut. Diese Spaltung kommt weiter unten noch einmal zurück.
Wenn dir jemand etwas verschlüsselt, liest dessen GnuPG deine Präferenzliste und wählt daraus das stärkste Verfahren, das beide können. Das ist der ganze Mechanismus. Daraus folgen zwei Dinge, die den meisten Leuten gegen den Strich gehen:
Deine eigenen personal-cipher-preferences schützen dich nicht. Die bestimmen, was du verschickst. Was Leute dir schicken, bestimmt allein das, was dein veröffentlichter Schlüssel ankündigt.
Die Präferenzen stehen in einer Signatur. Sie zu ändern heißt, die Selbstsignatur neu auszustellen. Und damit ist jede veröffentlichte Kopie deines Schlüssels veraltet, bis du sie erneuerst. Bei mir sind das sieben Kanäle und eine Änderung an der DNS-Zone.
Und wenn ein Schlüssel gar nichts ankündigt? Dann greift der Rückfall. RFC 4880 schreibt dafür 3DES vor, den verpflichtend zu implementierenden Algorithmus. Moderne GnuPG-Versionen landen dort allerdings nicht mehr: Seit der 2.3er Reihe verschlüsselt gpg grundsätzlich nicht mehr mit 64-Bit-Blockchiffren, dafür müsstest du ausdrücklich --allow-old-cipher-algos setzen. Der Rückfall endet deshalb bei AES-128. Nicht kaputt, aber eben auch nicht das, was der Schlüssel bekommen sollte.
Der Defekt, gemessen
Ein x-beliebiger Absender, der meinem brandneuen Schlüssel etwas verschlüsselt, sah das hier:
gpg: WARNING: cipher algorithm AES not found in recipient preferences
gpg: AES.CFB encrypted data
[GNUPG:] DECRYPTION_INFO 2 7 0 # 7 = AES128
Derselbe Absender, wenn er dem Schlüssel von 2023 schreibt, den der neue gerade ablöst:
[GNUPG:] DECRYPTION_INFO 2 9 0 # 9 = AES256
Der neue Schlüssel hatte überhaupt keinepref-sym-algos, keine pref-hash-algos und keine pref-zip-algos, dazu features 04 statt 05. Er wurde also schwächer angesprochen als sein Vorgänger, lautlos, und der einzige Hinweis darauf erschien auf einem Terminal, das jemand anderem gehört.
Die Ursache, und eine Korrektur an mir selbst
In meinen Arbeitsnotizen stand als Ursache: „mit einer --batch --gen-key-Parameterdatei ohne Preferences:-Zeile gebaut“. Das ist falsch. Eine Parameterdatei ohne diese Zeile erzeugt völlig ordentliche Präferenzen aus den eingebauten Defaults von GnuPG. Nachgeprüft, es kommt das hier heraus:
Die echte Ursache saß in der gpg.conf. Ich habe die Juli-Konfiguration in einem Wegwerf-Schlüsselbund nachgestellt, der Defekt war sofort wieder da. Danach habe ich die Datei halbiert, bis eine einzige Zeile übrig blieb:
Konfiguration im Test
Ergebnis
Juli-gpg.conf, unverändert
features: 04, gar keine pref-*
ohne disable-cipher-algo 3DES
pref-sym-algos: 9 8 7 2, features: 07
ohne alledisable-cipher-algo-Zeilen
pref-sym-algos: 9 8 7 2, features: 07
ohne cipher-algo AES256
weiterhin kaputt, features: 04
nurdisable-cipher-algo 3DES vorhanden
kaputt, features: 04
disable-cipher-algo 3DES plus explizite Preferences:-Zeile
sauber, pref-sym-algos: 9 8 7, features: 05
disable-cipher-algo 3DES ist also notwendig und hinreichend, um den Defekt auszulösen. Eine Zeile, mehr nicht.
Der Mechanismus dahinter ist die eigentliche Pointe des ganzen Beitrags. 3DES ist in OpenPGP der verpflichtend zu implementierende Algorithmus, und in der Standardliste steht er auch sichtbar drin: pref-sym-algos: 9 8 7 2 endet auf der 2, und die 2 ist 3DES. Nimmst du GnuPG diesen einen Algorithmus lokal weg, verschwindet nicht nur er aus der Liste, sondern die Liste als Ganzes.
Beim Warum bleibe ich vorsichtig. Dass GnuPG intern keine gültige Liste mehr konstruieren kann und deshalb gar keine schreibt, ist die naheliegende Erklärung, beweisen lässt sie sich von der Kommandozeile aus nicht. Belegt ist der Auslöser, nicht der Code-Pfad dahinter. Der Effekt selbst ist dagegen eindeutig: Die Härtungszeile hat nicht einen schwachen Algorithmus aus der Liste entfernt, sie hat die komplette Liste entfernt. Und damit dafür gesorgt, dass Absender zurückfallen, und zwar auf etwas Schwächeres als das, was ich gerade weghärten wollte.
Das Labor zum Selbernachbauen
Zwei Minuten, kein echter Schlüssel wird angefasst, alles passiert in Wegwerf-Verzeichnissen unter /tmp. Wenn du mir nicht glaubst, ist das hier der schnellste Weg, es selbst zu sehen:
Bei mir kommt das hier heraus, und die eine Zeile Unterschied ist der ganze Defekt:
nopref: gpg: WARNING: cipher algorithm AES not found in recipient preferences
gpg: AES.CFB encrypted data
withpref: gpg: AES256.OCB encrypted data
Die Reparatur
Der Fix selbst ist unspektakulär, ein einziger Befehl im Editiermodus. Weil setpref die Selbstsignatur neu ausstellt, braucht er den Hauptschlüssel. Das Ganze läuft also einmal im temporären GNUPGHOME von weiter oben, mit dem Offline-Schlüssel und dem aktuellen öffentlichen Stand:
gpg --edit-key $FPR
> setpref AES256 AES192 AES SHA512 SHA384 SHA256 ZLIB BZIP2 ZIP Uncompressed
> y
> save
Danach steht da pref-sym 9 8 7, pref-hash 10 9 8, pref-zip 2 3 1 0 und features 05. Der Fingerabdruck bleibt unverändert, und die Governikus-Zertifizierung, die Gegensignatur des alten Schlüssels, die Foto-UID und sämtliche Ablaufdaten überleben. Das schreibe ich so ausdrücklich hin, weil viele Leute Angst vor setpref haben und glauben, es beschädige den Schlüssel. Tut es nicht. Was es tut: Es stellt die Selbstsignatur neu aus. Und damit müssen anschließend alle sieben Veröffentlichungskanäle aufgefrischt werden.
Zur Sicherheit gleich hinterher: default-preference-list in der gpg.conf festgenagelt, und die disable-cipher-algo-Zeilen sind endgültig raus.
Wie schlimm war es wirklich?
Hier will ich ehrlich sein statt dramatisch, sonst wird das hier auch nur wieder eine von diesen „ich habe einen Bug gefunden“-Geschichten.
AES-128 statt AES-256 ist real, aber überschaubar. AES-128 ist nicht gebrochen, es gibt keinen praktischen Angriff darauf. Und erinnere dich an die Zahl von weiter oben: Der asymmetrische Teil dieses Schlüssels liefert ohnehin rund 128 Bit klassische Sicherheit. Gemessen an der reinen Schlüsselsuche war AES-128 also nicht das schwächste Glied in der Kette, es hat lediglich mit dem Rest gleichgezogen. Über Implementierungsfehler, Seitenkanäle oder Protokollschwächen sagt dieser Vergleich nichts. Die faire Einordnung lautet: Das war ein Hygiene- und Signalisierungsfehler, keine ausnutzbare Schwachstelle. Bemerkenswert ist er, weil er lautlos war, automatisch passierte und von einer Härtungsmaßnahme verursacht wurde.
Das fehlende MDC-Bit sah schlimmer aus, als es war.features 04 heißt, dass das Bit 0x01 fehlte, mit dem ein Schlüssel Modification Detection ankündigt. Auf dem Papier lädt das einen Absender dazu ein, auf ein Paket ohne Integritätsschutz zurückzufallen, und das ist die Ecke, aus der EFAIL kam. Gemessen kam aber das hier heraus:
[GNUPG:] DECRYPTION_INFO 2 7 0
[GNUPG:] GOODMDC
Ein Absender mit GnuPG 2.4.x in Standardkonfiguration hat trotz des fehlenden Bits ein integritätsgeschütztes SEIPD-v1-Paket erzeugt und GOODMDC gemeldet. MDC ist dort schlicht immer an. Herauskommen aus dem Integritätsschutz muss man in dieser Version aktiv wollen, etwa über --rfc2440, das ausdrücklich den alten Modus ohne MDC erzeugt. Die tatsächliche Integritätslücke gegenüber einem 2.4.x-Absender mit Standardeinstellungen war damit null.
Diese Aussage gilt exakt so weit wie die Messung und keinen Meter weiter. Über andere Implementierungen oder ältere GnuPG-Versionen sagt sie nichts, und genau dort könnte ein fehlendes MDC-Signal im Prinzip sehr wohl noch eine Rolle spielen. „Kein aktueller Absender ist betroffen“ wäre schlicht gelogen, und irgendwer würde es nachprüfen.
Was wirklich Alarm verdient, ist keines von beiden für sich, sondern die Art des Versagens. Ein Schlüssel kann strukturell perfekt sein und trotzdem still auf den nackten Rückfallwert heruntergehandelt werden, ohne dass irgendwer etwas davon mitbekommt. Die einzige Diagnose erscheint auf einer Maschine, die dir nicht gehört. Weder gpg --list-keys noch --check-sigs noch irgendeine Keyserver-Seite zeigt dir das. Du musst dir Signatur-Subpakete ansehen, und das macht praktisch niemand.
Ein Punkt bleibt offen: AEAD
Der reparierte Schlüssel steht bei features 05 und hat keine pref-aead-algos. Gemessen bedeutet das:
AES256.CFB encrypted data # reparierter Schlüssel, features 05
AES256.OCB encrypted data # frisch erzeugter Schlüssel, features 07 plus pref-aead-algos: 2
setpref hat das MDC-Bit zurückgeholt, aber nie eine AEAD-Ankündigung ergänzt, weil der Schlüssel aus dem kaputten Zustand heraus repariert und nicht neu erzeugt wurde. Nachrüsten ginge, setpref … OCB liefert pref-aead-algos: 2 und features 07, auch das habe ich geprüft.
Ich lasse es trotzdem so. AES256-CFB mit MDC ist solide. Vor allem aber ist AEAD genau die Stelle, an der OpenPGP derzeit auseinanderläuft: Das AEAD von GnuPG 2.4 folgt der LibrePGP-Linie, RFC 9580 spezifiziert eine andere Konstruktion namens SEIPD v2. AEAD auf einem breit veröffentlichten Schlüssel anzukündigen bringt heute eine marginale Verbesserung und ein echtes Interoperabilitätsrisiko. Wer sich für die andere Baustelle im selben Themenfeld interessiert: Was in einem modernen Handshake steckt, habe ich am Beispiel X25519MLKEM768 auseinandergenommen. Dasselbe Argument gilt übrigens für force-ocb in meiner lokalen Konfiguration, das betrifft nur, was ich selbst verschicke, und ist eine bewusst etwas vorwärtsgewandte Entscheidung.
Ehrliche Gesamteinschätzung
Die Kryptografie ist in Ordnung. Ed25519, X25519, SHA-512 und AES-256 sind eine moderne, unaufgeregte Auswahl ohne bekannte praktische Schwächen, bei rund 128 Bit klassischer Sicherheit.
Die Struktur ist besser als der Durchschnitt. Hauptschlüssel nur zum Zertifizieren, getrennte Unterschlüssel, begrenzte Laufzeit von fünf Jahren, vorab erzeugtes Widerrufszertifikat und ein Backup, das nachweislich wiederherstellbar ist.
Das schwächste Glied ist kein Algorithmus. Es ist die Tatsache, dass die drei Alltags-Unterschlüssel auf einer dauerhaft laufenden Arbeitsmaschine liegen. Wer diese Maschine und die Passphrase bekommt, erzeugt bis zum Widerruf vollgültige Signaturen in meinem Namen und liest mein Archiv mit. Nach außen ist er also erst einmal ich. Was er nicht bekommt, ist der Identitätsanker: keine neue UID zertifizieren, keinen neuen Unterschlüssel binden, und der Fingerabdruck bleibt meiner. Ein kompromittierter Unterschlüssel fliegt raus und wird ersetzt, die Identität überlebt. Eine Smartcard würde den Rest weiter verkleinern, weil sich Schlüsselmaterial von der Karte nicht kopieren lässt. Sie löst es nicht: Steckt die Karte und ist sie entsperrt, wird eben auf der Karte signiert. Auf der Liste steht sie trotzdem.
Das Vertrauen in die Bindung ist geschichtet, nicht absolut. DNSSEC-signiertes DANE und selbst gehostetes WKD sind stark. Die Governikus-Zertifizierung ist die einzige echte Namens-Bindung. Das klassische Web of Trust trägt praktisch nichts mehr bei, die persönlichen Signaturen auf dieser Identität hängen an einem Schlüssel von 2011.
Post-Quantum: bewusst nicht adressiert. GnuPG 2.4.4 kann kein PQC, und die PQC-Arbeit in OpenPGP steckt noch im Entwurfsstadium mit praktisch keiner Interoperabilität. Curve25519 bietet gegen einen kryptografisch relevanten Quantencomputer keinerlei Widerstand, „heute sammeln, später entschlüsseln“ trifft also auf alles zu, was heute an diesen Schlüssel verschlüsselt wird. Ein hybrider Unterschlüssel lässt sich später ergänzen, ohne den Schlüssel neu zu bauen. Das ist eine der Auszahlungen des Unterschlüssel-Aufbaus von oben.
Was man daraus mitnimmt
Vier Punkte, und die gelten weit über OpenPGP hinaus:
Härtung kann schwächen. Einen verpflichtenden Basisalgorithmus zu entfernen hat die Verhandlung nicht verengt, sondern zerstört. Bevor du etwas abschaltest, das eine Spezifikation vorschreibt, sieh nach, was die Maschinerie drumherum macht, wenn es fehlt.
Prüfe das Artefakt, nicht den Befehl.gpg --batch --gen-key ist mit Rückgabewert 0 durchgelaufen und hat einen wunderschön aussehenden Schlüssel erzeugt. Sichtbar war der Defekt ausschließlich in --list-packets.
Konfiguration und Schlüssel sind nicht unabhängig. Der Schlüssel hat einen Defekt dauerhaft von der Konfiguration geerbt, die in den fünfzehn Sekunden aktiv war, in denen er entstand.
Manche Defekte sieht man nur von außen. Das Symptom erschien auf dem Terminal des Absenders. Teste deinen eigenen Schlüssel so, wie ein Fremder ihn benutzt. Genau dafür ist die Schleife mit mktemp -d und --locate-external-keys weiter oben da.
Und die Kurzfassung für alle, die nur bis hierher gescrollt haben: Ein moderner Schlüssel ist nicht automatisch ein korrekt konfigurierter Schlüssel. Der Unterschied steckt in Signatur-Subpaketen, die dir kein einziges Standardwerkzeug von sich aus zeigt.
Wenn du deinen eigenen Schlüssel gerade nachgeprüft hast und dort etwas anderes steht als erwartet, oder wenn ich mich irgendwo irre, dann dürft ihr mich sehr gerne fragen.
Diese Geschichte fing als PHP-Problem an und endete mehrere Wochen später in einem Bug im FreeBSD-Basissystem, ganz unten im Runtime-Linker. Dazwischen liegen mindestens vier falsche Fährten, ein Crash, der bei jedem Lauf ein anderes Opfer suchte, und die schöne Erkenntnis, dass man eine Heap-Korruption nicht mit einzelnen Watchpoints fängt. Ich schreibe das bewusst mit allen Sackgassen auf, weil genau die der lehrreiche Teil sind. Wer nur die Auflösung will, springt ans Ende.
Das Symptom
PHP 8.4 auf FreeBSD 15 (amd64), im Zusammenspiel mit einer selbst gehosteten Nextcloud. Jeder occ-Aufruf und jeder Cron-Lauf lieferte sein Ergebnis korrekt ab und segfaultete danach. Signal 11, jedes Mal, mit schöner Regelmäßigkeit ein Core-Dump von rund 2,2 GB. Die Ausgabe stand vollständig da, bevor es knallte. Der Crash passierte erst im Module-Shutdown, also beim Aufräumen, nachdem die eigentliche Arbeit längst erledigt war.
Funktional war das harmlos. Ärgerlich war der Rest. Das dmesg füllte sich mit Zeilen der Sorte:
pid 12345 (php), jid 0, uid 80: exited on signal 11 (core dumped)
Die Platte lief mit 2,2-GB-Cores voll, und es gab einen unangenehmen Nebeneffekt: hängende Background-Jobs. Wenn PHP-FPM mitten in einem Nextcloud-Cron-Job segfaultet, wird das reserved_at in der Tabelle oc_jobs nie zurückgesetzt. Der Job gilt damit als dauerhaft in Bearbeitung und läuft nie wieder an. Aus einem kosmetischen Shutdown-Crash wurde so ein echtes Betriebsproblem.
Erste falsche Fährte: OPcache JIT
Ein Segfault in PHP, der frische JIT im Spiel: der erste Verdacht war schnell da. Also habe ich mich durch die JIT-Stufen gearbeitet. Tracing-JIT mit opcache.jit=1255, dann Function-JIT mit 1205, dann JIT komplett aus mit 0. Es crashte durch alle Stufen hindurch unverändert weiter.
JIT war auf FreeBSD 15 zwar tatsächlich für sich genommen kaputt und ist bei mir seitdem aus. Aber die Ursache für den Shutdown-Crash war er nicht. Erste Fährte verworfen.
Die Versions- und Build-Jagd
Nächster Verdacht: ein kaputter Build oder eine ABI-Unstimmigkeit zwischen dem PHP-Core und einer Extension. Also PHP komplett aus den Ports neu gebaut, damit Core und alle Extensions garantiert dieselbe Version tragen. Danach Symbol-Builds fürs Debugging. Und dann durch die Punktversionen gehangelt: 8.4.16, .18, .19, .20, .21, .22. Jede einzelne crashte gleich.
Damit war eine wichtige Sache geklärt: Build, Version und CFLAGS sind nicht der Unterschied. Was sich nicht ändert, wenn man alles daran ändert, liegt woanders.
Eine Lehre am Rande, die mich unnötig Zeit gekostet hat: --enable-debug wechselt das ABI-Verzeichnis der Extensions. Danach laden sämtliche als Paket installierten Extensions nicht mehr, weil sie im falschen Verzeichnis gesucht werden. Wer nur Debug-Symbole will, ohne das ABI zu verbiegen, baut so:
make CFLAGS+=" -g" STRIP=
Die Crash-Site per lldb aus dem Core
Das FreeBSD-Basissystem bringt kein gdb mit, dafür lldb. Aus dem Core kommt man so an den Backtrace:
Die crashende Instruktion war cmpq %rbx, 0x20(%r15). Der Offset 0x20 ist in zend_property_info das Feld ce, der Zeiger auf den Klassen-Eintrag. Das Register r15 stand auf 0x6b588e9c404, unaligned und außerhalb des Heaps. Das riecht nach einem Use-after-Free auf geteilte interne Klassen-Metadaten.
Ein genauerer Walk durch die Strukturen korrigierte meine erste Annahme. Der Offset 0x20 liegt nicht nur in zend_property_info, sondern genauso in zend_class_constant auf dem ce-Feld. Die crashende Schleife lief nicht über die Properties, sondern über die Klassen-Konstanten, also die constants_table. Die crashende Klasse war Pdo\Pgsql, eine der neuen internen Subklassen aus dem PHP-8.4-RFC zu den PDO-treiberspezifischen Subklassen, die von PDO erbt. Mein Verdacht drehte sich damit auf etwas 8.4-Spezifisches: Vererbung von internen Konstanten, vielleicht im Umfeld der Property Hooks.
Der Crash wandert
Und jetzt wurde es unangenehm. Die Crash-Site war nicht stabil. Von Lauf zu Lauf sah ich mal destroy_zend_class, mal zend_type_release, mal zend_interned_strings_dtor. Mal war das Opfer Pdo\Pgsql, mal ein arg_info von RedisCluster, mal ein zend_type, mal ein DateTimeZone.
Das ist das klassische Bild eines einzelnen korrumpierenden Schreibzugriffs mit wechselndem Opfer. Wer getroffen wird, hängt allein am Heap-Layout des jeweiligen Laufs. Das erklärt rückblickend, warum die vermeintlich genaue Klasse jedes Mal anders aussah. Ich hatte die ganze Zeit das Spätsymptom analysiert, nicht die Ursache. Als Beispiel eine ganz andere Crash-Site vom zweiten Rechner:
Das Opfer hier war ein permanenter interned String. Das sind die intern deduplizierten, prozessweit nur einmal abgelegten Zeichenketten, die PHP überall wiederverwendet, in diesem Lauf der Redis-Kommandoname zintercard. Sein Header war zerschossen, beim Freigeben faultet der Destruktor auf einem ZendMM-Block, der gar nicht mehr gemappt ist. Wieder ein anderer Tatort, dasselbe Muster: irgendwer schreibt einmal quer, und wer danach als Erstes über die zerstörte Stelle stolpert, nimmt den Fall.
Upstream-Issue GH-21995, und die Richtung dreht sich
An diesem Punkt habe ich das Ganze bei php-src als Issue GH-21995 aufgemacht. Zwei Reaktionen haben die Richtung gedreht.
Zuerst @iliaal, einer der PHP-Maintainer:
Cannot reproduce on Linux (ASAN, Valgrind all clean on 37 extension build), so if this is valid it might be FreeBSD specific.
ASAN und Valgrind sauber auf Linux ist ein starkes Indiz gegen einen klassischen Use-after-Free im Zend-Speichermanager. Ein solcher Fehler würde unter ASAN sofort auffliegen. Wenn er das nicht tut, sitzt das Problem woanders, vermutlich unterhalb von PHP.
Dann bestätigte @CamilleScholtz das Verhalten unabhängig, auf PHP 8.5.6, FreeBSD 15, und ausdrücklich nicht in einem Jail. Damit fielen zwei bequeme Ausreden weg: es war weder meine spezielle Konfiguration noch etwas, das in 8.5 schon behoben gewesen wäre.
Die VM reproduziert nicht, ein Heisenbug
Auf Bare-Metal crashte die unveränderte Paket-Installation praktisch bei jedem Lauf, gefühlt zu hundert Prozent. In einer VM dagegen kam ich auf rund 650 saubere Läufe, ohne einen einzigen Crash. Und sobald ich mit lldb und Watchpoints an das Objekt heranging, das ich für das Opfer hielt, verschob sich das Opfer. Die Beobachtung selbst veränderte das Heap-Layout und damit den Ausgang.
Das ist ein Heisenbug im Lehrbuchsinn. Ein einzelner Watchpoint auf ein einzelnes Objekt bringt hier nichts, weil der nächste Lauf ein anderes Objekt zerstört. Ich brauchte eine Messung, die gegen das Layout robust ist.
Messen statt raten: der Tabellen-Diff
Statt ein einzelnes Objekt zu beobachten, habe ich die ganze Tabelle der permanenten interned Strings an definierten Checkpoints verglichen. Ein eigenes lldb-Python-Skript zieht an jedem Checkpoint einen Snapshot der Tabelle und difft gegen den vorherigen. So ist es egal, welches konkrete Objekt in diesem Lauf getroffen wird, denn ich sehe jede Änderung an der ganzen Region.
Das Ergebnis war der erste harte Datenpunkt seit Wochen. Der korrumpierende Schreibzugriff passiert während des Spawns, genauer im Intervall zwischen posix_spawnp und posix_spawn_file_actions_destroy. Überschrieben wird ein zusammenhängender Block von rund 480 Byte, gefüllt mit 8-Byte-Zeigern. Das sieht aus wie Stack-Frames, die dort hingehören, wo sie nicht hingehören. Damit war klar: das ist keine PHP-interne Speicherverwaltung, das ist der Spawn.
Die Batterie: den Auslöser einkreisen
Jetzt konnte ich gezielt testen. Je 20 Läufe pro Kandidat. Nur proc_open crashte, und zwar 20 von 20. popen, exec, system, shell_exec, fopen, dazu Heap-Churn-Kandidaten wie str_repeat und range: alle 0 von 20. Es ging also nicht um fork und exec im Allgemeinen, auch nicht um Heap-Belastung, sondern spezifisch um proc_open.
Und dann entschied die Form des Aufrufs über Crash oder kein Crash:
Aufruf
Spawn-Pfad
Crash
proc_open(["true"], …) (relativ)
posix_spawnp → __libc_execvpe (PATH-Suche)
ja
proc_open(["/usr/bin/true"], …) (absolut)
posix_spawnp → execvPe direkt
nein
proc_open("true", …) (String)
posix_spawn (/bin/sh -c) → _execve
nein
Nur der relative Befehl ohne Schrägstrich im Namen crasht, weil nur der die PATH-Suche im Kind auslöst. Die Länge des PATH war dabei egal, auch mit einem einzigen Eintrag crashte es. Das grenzt es sauber gegen den alten Long-PATH-Overflow ab: es geht nicht um einen zu langen PATH, sondern um einen intrinsischen Stack-Verbrauch im no-slash-Suchzweig.
Das Minimal-Repro ist entsprechend kurz und kommt ganz ohne Framework aus:
php -r 'proc_open(["date"], [], $pipes);' # → signal 11
Ein Detail fehlt noch, und es ist wichtig: der Crash braucht den vollen Satz geladener Extensions. Ein Minimalsatz von 17 Extensions reicht, aber das Entfernen irgendeiner einzelnen davon stoppt den Crash. Konkret dieser Satz: session, dom, iconv, imagick, intl, pdo, pgsql, phar, simplexml, sodium, xml, xmlwriter, zip, zlib, memcached, pdo_pgsql, redis. Viele geladene Shared Objects plus ein proc_open: beides zusammen ist nötig, keins allein reicht. Diese Beobachtung war später der Schlüssel zur Ursache, auch wenn ich das zu dem Zeitpunkt noch nicht wusste.
Runter in die libc-Quelle
Der Spawn führte mich in /usr/src/lib/libc/gen/posix_spawn.c. Auf amd64 startet do_posix_spawn das Spawn-Kind so:
Für ein {"true", NULL} sind das rund 4128 Byte. Das Entscheidende an RFSPAWN beziehungsweise rfork_thread: das Kind bekommt bis zum exec einen geteilten Adressraum, ähnlich wie bei vfork. Kind und Eltern arbeiten bis zum exec also auf demselben Speicher. Bei einem relativen Kommando läuft das Kind über __libc_execvpe in die PATH-Suche. Meine Hypothese an dieser Stelle war: das Kind erschöpft seine gut 4 KB Stack und schreibt in den direkt darunter liegenden Heap des Elternprozesses. Das würde exakt zu dem 480-Byte-Block aus Zeigern passen, den der Tabellen-Diff gesehen hatte.
Der Beweis: guardspawn
Eine Hypothese ist nur so gut wie ihr Experiment. Also habe ich guardspawn.c geschrieben, einen kleinen Interposer per LD_PRELOAD, der rfork_thread(RFSPAWN) abfängt und dem Kind einen selbst kontrollierten Stack unterschiebt. Zwei Varianten, zwei klare Antworten:
Gebe ich dem Kind 1 MB Stack, fällt der Crash auf 0 von 30. Baseline ohne Interposer war 30 von 30.
Gebe ich dem Kind wieder nur gut 4 KB, aber mit einer Guard-Page direkt darunter, stirbt das Spawn-Kind selbst mit SIGSEGV, unabhängig von der genauen Stelle.
Damit war die Kernaussage bewiesen: das Kind erschöpft den knapp 4 KB großen Spawn-Stack. Genauso ehrlich habe ich es aber auch in den Report geschrieben: welcher exakte Frame den Puffer überläuft, war zu dem Zeitpunkt nicht bewiesen. Ein alleinstehendes C-Programm triggerte den Fehler nicht, das Ganze hing an der Last des Prozesses. Mein Verdacht ging Richtung Runtime-Linker, aber das war noch eine Vermutung, kein Beweis.
Eine ehrliche Selbstkorrektur
Zwischendurch hatte ich mich verrannt und einen Stack-Underflow zu bestimmt behauptet. Ein zweiter, kritischer Blick von außen und ein eigener Read der Quelle korrigierten das: execvPe selbst verbraucht deutlich weniger als 4 KB, und absolute Kommandos laufen auch durch execvPe und crashen trotzdem nicht. Der Unterschied liegt also nicht in einem bewiesenen Overflow in execvPe, sondern im no-slash-Zweig der PATH-Suche. Ich habe das im Report deshalb als Lokalisierung formuliert, nicht als bewiesenen Mechanismus.
Dazu gehört auch das ehrliche Eingeständnis, dass alle meine früheren php-src-Hypothesen falsch waren: die Property Hooks, der vermeintliche Use-after-Free auf Klassen-Konstanten, die interned-String-Korruption, die pgsql-Verdächtigungen. Das war alles die wandernde Fault-Site, das Spätsymptom, nie die Ursache. Wer wochenlang das Symptom seziert, baut sich überzeugende Theorien über das Symptom. Das gehört in so einen Bericht hinein, nicht wegretuschiert.
Der Bugreport ans FreeBSD-Basissystem
Mit dieser Lokalisierung habe ich den Bug im FreeBSD-Basissystem eingereicht: Bug 295991. Das php-src-Issue GH-21995 habe ich als kein php-src-Bug geschlossen und beide Seiten miteinander verlinkt.
Wichtig war mir die Abgrenzung zu FreeBSD-SA-20:18 beziehungsweise CVE-2020-7458 von 2020. Das war der Long-PATH-Overflow an genau dieser Code-Stelle, längst behoben. Mein Fall ist die gleiche Gegend im Code, aber unabhängig von der PATH-Länge. Es ist bewusst keine Sicherheitsgeschichte, sondern ein Stabilitätsproblem, ausgelöst von völlig legitimem Code beim Aufräumen.
Praktischer Nebenbefund für alle, die sich an der Anubis-Sperre der FreeBSD-Bugzilla stören: den Status eines Bugs bekommt man ohne Browser bequem per REST:
Jetzt kam der Teil, für den sich die Mühe des sauberen Reports gelohnt hat. @bdrewery, FreeBSD-Committer, bestätigte und reproduzierte den Fehler noch bequemer als ich, direkt über den www/nextcloud-Port mit occ status in einer Schleife:
there is some random corruption that shows up with php on exit when loaded with many extensions. Raising the stack size in posix_spawn avoids the problem.
Zur Ehrlichkeit gehört der Seitenhieb, den ich mir dabei eingefangen habe: den Text meines Reports nannte er einen unreadable AI mess. Inhaltlich hat er den Fall getroffen, die Form hat genervt. Das war eine gute und verdiente Lektion über Report-Stil, auf die ich am Ende noch einmal zurückkomme.
@kevans hat den Mechanismus dann endgültig festgenagelt, und zwar an einer Stelle, an der ich nur einen Verdacht hatte. Nicht execvPe sprengt den Stack, sondern der Runtime-Linker beim Lazy-Binding der Symbole. Der Pfad ist _rtld_bind → find_symdef → symlook_default → donelist_init. Und donelist_init macht ein alloca, dessen Größe mit der Zahl der geladenen Shared Objects skaliert:
Genau deshalb triggern schwer gelinkte Prozesse den Fehler und Spielzeug-Programme nicht. obj_count ist bei PHP mit dem vollen Extension-Satz groß, das alloca entsprechend fett, und auf dem gut 4 KB kleinen Spawn-Stack ist dann Schluss. Das deckt sich exakt mit meiner rtld-Vermutung aus dem Report und erklärt auch das 17-Extensions-Minimum: unter einer gewissen Zahl geladener Objekte bleibt das alloca klein genug.
Der Fix
Der Fix kam von @kib als Diff D57908. Die erste Revision regressierte und ließ eine www/onlyoffice-Umgebung crashen, mit ld-elf.so.1-Faults in beam.smp und x2t. Das war ein Multithreading-Problem, das kib noch vor dem Commit behoben hat. Danach ging es nach main:
1e370f0 „rtld: stop using unbound alloca()“ vom 29. Juni 2026. Die alloca-Aufrufe in der DoneList und in map_object wandern in den Heap, sobald sie groß werden. Vermerk MFC after: 1 week.
3de9dc5 vom 30. Juni 2026. Ein libc-Regressionstest, der eine Dummy-Shared-Library mehrfach mappt und mit einer Guard-Page arbeitet, um den Underflow zuverlässig zu triggern.
Beim Schreiben dieses Beitrags steht der MFC nach stable/15 an. Für ein 15.1-RELEASE kommt der Fix mit einem der künftigen 15.x-Patches. Bis dahin ist der Workaround simpel: absolute Pfade in proc_open vermeiden den crashenden no-slash-Zweig. Das ist Symptombekämpfung, kein Fix. Und wer nur das volllaufende Dateisystem im Blick hat, räumt die harmlosen Cores einfach weg.
Warum am Ende alles zusammenpasst
Das Schöne an der Auflösung ist, dass sie jedes einzelne der vielen Rätsel erklärt, die mich wochenlang in die Irre geführt haben:
Nur proc_open crasht, weil es das einzige PHP-Konstrukt ist, das posix_spawnp nutzt.
Nur relative Kommandos crashen, weil nur sie die PATH-Suche und damit das Lazy-Binding im Kind auslösen.
Nur FreeBSD auf amd64, weil der rfork_thread-Pfad mit dem kleinen malloc-Stack amd64- und i386-spezifisch ist.
ASAN und Valgrind sauber auf Linux, weil glibc posix_spawn ganz anders baut.
Der volle Extension-Satz nötig, weil viele Shared Objects das alloca im rtld erst groß genug für den Überlauf machen. Und die vielen permanenten interned Strings legen zusätzlich die späteren Opfer genau unter den Spawn-Puffer.
Zur Methode, und zum Report-Stil
Zwei Dinge nehme ich technisch mit. Erstens: eine Heap-Korruption mit wanderndem Opfer fängt man nicht mit einzelnen Watchpoints, weil das Beobachten das Layout verschiebt und damit das Opfer. Was funktioniert, sind layout-robuste Tabellen-Diffs an definierten Checkpoints. Nicht ein Objekt anstarren, sondern die ganze Region vorher und nachher vergleichen. Zweitens: ein LD_PRELOAD-Interposer mit Guard-Page ist ein billiges, definitives Ja-oder-Nein-Experiment für die Frage, ob ein Stack-Overflow vorliegt. Ein sauberes Experiment schlägt zehn plausible Theorien.
Und dann die Lektion, die mir @bdrewery verpasst hat. Ein Bugreport, der die ganze Hypothesenkette in den Body kippt, ist für den Leser eine Zumutung, egal wie korrekt die Analyse ist. Die richtige Form sind drei bis vier Sätze Kern ganz oben, das reproduzierbare Minimal-Beispiel gleich dahinter, und der ganze Ermittlungskrimi darunter für die, die ihn brauchen. Der Inhalt hat gestimmt, deshalb wurde der Bug gefixt. Aber die Form hätte den Committern viel Zeit gespart. Nächstes Mal Kern zuerst.
Ähnliche Geschichte im Notebook, im Basissystem festgefahren, oder einfach eine Meinung zum Report-Stil? Dann einfach fragen.
Anfang März habe ich hier beschrieben, wie ich den NEXT Biometrics NB-2020-U in meinem Fujitsu Notebook unter Linux zum Laufen gebracht habe. Die ganze Arbeit lief am Ende auf eine einzige Product ID hinaus: 0x2020 im bestehenden nb1010 Treiber, weil der NB-2020-U denselben Sensor Die wie der NB-1010-U nutzt. Der Beitrag endete mit dem üblichen Cliffhanger: Merge Request eingereicht, CI grün, warten auf das Review durch die Maintainer.
Marco Trevisan, einer der libfprint Maintainer, hat den Patch auf den aktuellen master rebased, die Pipeline noch einmal durchlaufen lassen und ihn am 2. Juli 2026 per Auto-Merge aufgenommen (Commit 0fa670f). Blockierende Review-Kommentare gab es keine. Der Patch war klein und die Beweislage eindeutig: gleicher Sensor, gleiches USB Protokoll, gleicher Treiber, nur eine zusätzliche ID in der Tabelle.
Was das für Betroffene heißt
Für alle mit demselben Fingerabdruckleser im Notebook: Ab der nächsten libfprint Version wird der NB-2020-U out of the box erkannt. Kein eigener Patch mehr, kein Selberbauen. Enrollment und Verifikation über fprintd laufen dann direkt, sobald die Distribution die neue libfprint Version ausliefert. Wer nicht warten möchte, nimmt weiterhin den Patch aus dem ersten Beitrag oder baut direkt vom aktuellen master.
Der zweite Leser aus derselben Familie, der NB-2033-U mit seinem komplett eigenen Protokoll, hat einen eigenen Treiber von Grund auf bekommen. Dieser Merge Request !574 liegt noch beim Review, ist aber frisch auf den neuen master rebased und die Pipeline ist grün. Sobald auch der durch ist, folgt ein weiterer kurzer Nachtrag.
Ein einzelner ZFS-Pool aus zwei 7200-rpm-Platten war durch Metadaten-Random-I/O ausgebremst. Lösung ganz ohne Neuaufbau: die vorhandenen SSDs zu einem gespiegelten special vdev für Metadaten plus gespiegeltem SLOG umgebaut, zwei zpool add-Befehle im laufenden Betrieb. Resultat: Metadaten-Leselatenz von rund 46 ms auf rund 455 µs, also etwa Faktor hundert, bei voll erhaltener Verschlüsselung und Redundanz.
Drehende Platten sind ein ehrliches Stück Technik. Sie speichern viele Terabyte für wenig Geld und liefern bei sequenziellem Zugriff ordentlichen Durchsatz. Ihre Achillesferse ist der zufällige Zugriff auf viele kleine Blöcke, denn jede Kopfbewegung kostet Latenz im zweistelligen Millisekundenbereich aus Seek und Rotationswartezeit. Und genau dieses ungünstigste Muster produziert ein Copy-on-Write-Dateisystem wie ZFS am laufenden Band: Metadaten. Verzeichnis-ZAPs, dnodes, indirekte Blöcke, also die Block-Pointer-Bäume, dazu Spacemaps. Jedes ls, jedes stat, jeder Snapshot-Vergleich, jeder Scrub und jede find-Traversierung wühlt sich durch viele kleine, über die ganze Platte verstreute Metadatenblöcke. Auf einer HDD ist das der teuerste Spaß, den man haben kann.
Ich hatte genau diesen Schmerz auf einem dedizierten Server: ein bewusst simpel gehaltener ZFS-Pool, zwei Enterprise-SATA-Platten im Mirror als Kapazitätsspeicher, und ein nagender Verdacht, dass die Spindeln der Flaschenhals sind. Die spannende Frage war nicht, ob man das beheben kann, sondern wie elegant. Die Antwort heißt allocation classes, konkret ein special vdev. Und das Schöne daran: Der Umbau lief komplett im laufenden Betrieb, ohne den Pool neu aufzubauen, ohne Downtime, mit zwei Befehlen. Dieser Beitrag zeigt den ganzen Weg, inklusive der Baseline-Messung, die den Engpass erst beweist, eines Verschlüsselungs-Stolpersteins beim Umbau und der ehrlichen Frage, was so ein special vdev wirklich bringt.
Die Ausgangslage
Der Server läuft auf FreeBSD 15.1-RELEASE (amd64, 12 CPU-Threads, 64 GiB RAM). Ein einziger ZFS-Pool, 2023 ganz bewusst als schlichter Mirror angelegt:
Das Daten-vdev sind zwei 7200-rpm-Enterprise-SATA-Platten mit je 2 TB als Mirror (mirror-0, rund 1,8 TiB nutzbar), der eigentliche Kapazitätsspeicher.
Dazu zwei Datacenter-SATA-SSDs mit je 240 GB und Power-Loss-Protection. Die waren vorher suboptimal genutzt: eine als einzelner, nicht gespiegelter SLOG, die andere als L2ARC.
ARC-Limit anfangs 16 GiB, poolweit compression=lz4 und atime=off von Anfang an.
ashift=12 erzwungen über vfs.zfs.vdev.min_auto_ashift=12, also 4K-Sektor-Alignment, korrekt auch dann, wenn die Platten brav 512-Byte-Sektoren melden.
Die Power-Loss-Protection der SSDs ist kein Detail am Rande, sondern später für die SLOG-Sicherheit relevant: Eine SSD ohne Pufferschutz darf bei einem synchronen Write nicht behaupten, die Daten lägen sicher, solange sie noch im flüchtigen Cache stehen. Datacenter-SSDs mit Kondensator-gestütztem Cache dürfen das, und genau das braucht ein SLOG.
Erst messen, dann bauen
Bevor ich auch nur eine Partition angefasst habe, kam die wichtigste Phase: messen. Ohne Baseline kauft man Hardware nach Bauchgefühl und tunt am falschen Ende. Also lief ein eigener, delta-basierter Sampler über 30 Minuten, 90 Samples zu je 20 Sekunden. Er liest sysctl-Counter für CPU, ARC und Netz sowie iostat -x für die Platten-Busy und die Latenzen. Die wichtigste Spalte zur Einordnung der Last ist net-out, also der ausgehende Netzdurchsatz als Proxy dafür, was während des Laufs tatsächlich los war.
Das Ergebnis der Baseline (16 GiB ARC, alte SSD-Rollen) war eindeutig:
Der Flaschenhals ist der HDD-Mirror. Busy im Mittel 58 bis 62 %, Spitzen bis 100 bis 104 %, Latenz im Mittel rund 8 ms, unter Last bis 20 bis 24 ms. In 16 % der Samples war die HDD zu 95 % oder mehr ausgelastet, also gesättigt.
Die CPU war zu rund 95 % idle, RAM frei, der Netz-Peak lag bei rund 68 Mbit/s, also nur etwa 7 % des Gigabit-Links. Weder CPU noch RAM noch Netz waren das Limit.
Der ARC klebte an seinem 16-GiB-Limit (Mittel 15,7 GiB) bei einer Hit-Rate von rund 94,7 %. Der ARC war schlicht ausgehungert und hätte mehr RAM sofort genutzt.
Der einzelne SLOG lief bei rund 42 % Busy, war also nicht gesättigt. Die Spindeln waren das Limit, nicht der SLOG.
Das ist die didaktische Pointe, die ich jedem ans Herz lege: Ohne diese Messung wüsste ich nicht, ob CPU, RAM, Netz oder Platten klemmen, und ich wüsste nicht, ob das Problem auf der Lese- oder der Schreibseite liegt. Messen ist kein Nice-to-have, sondern die Voraussetzung dafür, das richtige Bauteil zu kaufen und am richtigen Hebel zu drehen.
Was ein special vdev ist, und warum nicht einfach All-SSD
Allocation classes sind ein OpenZFS-Feature (feature@allocation_classes), mit dem ein Pool mehrere Klassen von vdevs führen kann. Das special vdev ist die Klasse für Metadaten: ZFS legt dnodes, indirekte Blöcke und poolweite Metadaten bevorzugt dort ab statt auf dem normalen Daten-vdev. Über die Dataset-Property special_small_blocks kann man zusätzlich kleine Datenblöcke unterhalb einer einstellbaren Schwelle aufs special vdev ziehen. Im Kern verschiebt man also genau die Datenklasse, die eine HDD am schlechtesten beherrscht, auf ein Medium, das genau dafür gebaut ist.
Dass das hier der richtige Hebel ist, ist nicht geraten, sondern messbar: Der ARC dieses Servers besteht zu rund 85 % aus Metadaten, konkret 17,4 GB Metadaten gegenüber 3,0 GB Daten im ARC. Der Workload ist also metadaten-dominiert. Metadaten auf SSD zu verlagern trifft den Engpass damit an der Wurzel, denn das ist exakt der Random-I/O, an dem die Platten am meisten leiden. Bevor ich mich für das special vdev entschieden habe, standen aber andere Optionen auf dem Tisch:
Kompletter All-SSD-Pool aus zwei großen SSDs: der sauberste Komplettfix, aber teuer und ein großer Umbau mit Pool-Neuaufbau und vollständiger Datenmigration. Overkill, wenn der Großteil der Kapazität aus kalten, überwiegend sequenziell gelesenen Daten besteht.
Mehr RAM und ARC: hilft nur der Leseseite und nur, solange der Working Set in den ARC passt. Schreib-Metadaten müssen trotzdem auf stabilen Speicher, daran ändert RAM nichts.
L2ARC behalten: abgeschafft. Bei 24 GiB ARC lag die Lese-Hit-Rate schon bei rund 98,5 %. Der L2ARC brachte nur rund 1,3 % zusätzliche Reads, ist flüchtig (nach einem Reboot leer) und kostet sogar ARC-RAM für seine Header. Das Kosten-Nutzen-Verhältnis war negativ.
special vdev: die gewählte Lösung. Nutzt die vorhandenen SSDs, kein Pool-Neuaufbau, adressiert exakt den gemessenen Metadaten-Schmerz, inkrementell und live im Betrieb machbar.
Der Umbau Schritt für Schritt
Aus dem alten Zustand mit einem einzelnen SLOG und einem L2ARC sollte ein SLOG-Mirror plus ein special-vdev-Mirror werden. Beide SSDs werden also jeweils zur Hälfte für beide Zwecke genutzt, jeweils gespiegelt. Zuerst die alten Single-Rollen entfernen:
zpool remove zroot ada3p1 # alter L2ARC
zpool remove zroot ada2p1 # alter (einzelner) SLOG
Und hier kam der erste Stolperstein, der so lehrreich ist, dass er einen eigenen Absatz verdient. Das SLOG-Remove schlug zunächst fehl:
cannot remove ada2p1: Mount encrypted datasets to replay logs
Die Ursache: Es existierten verschlüsselte Datasets, deren Keys in diesem Boot nie geladen waren. Der SLOG lässt sich nicht entfernen, solange potenziell noch nicht abgespielte ZIL-Einträge für gesperrte Datasets vorliegen, denn ZFS müsste diese Einträge zum Replay erst entsperren. Erst nach dem Aufräumen und Entsperren ließ sich der SLOG sauber entfernen. Das ist gleichzeitig die perfekte Überleitung zum Verschlüsselungskapitel weiter unten, denn es zeigt, wie tief native ZFS-Encryption in den ZIL-Pfad eingreift.
Danach die SSDs neu partitionieren, sauber 1-MiB-aligned. Pro SSD wird p1 16 GiB groß (SLOG) und p2 rund 208 GiB (special). Das Ergebnis von gpart show ada2 ada3:
Beide Befehle bewusst ohne -f. So bleibt der Redundanz-Schutz von ZFS als Sicherheitsnetz aktiv: ZFS verweigert ein nicht-redundantes special oder log neben einem Mirror, solange man es nicht ausdrücklich erzwingt. Und genau dieses Verweigern ist hier gewollt.
Die wichtigste Warnung dieses Beitrags: Ein special vdev ist nicht optional für die Pool-Integrität. Verliert man ein nicht gespiegeltes special vdev, ist der gesamte Pool verloren, denn die Metadaten liegen dort, und ohne sie ist der Rest unlesbar. Das special vdev muss mindestens so redundant sein wie das Daten-vdev, hier also als Mirror. Für den SLOG gilt das in dieser Schärfe nicht, ein verlorener SLOG kostet nur die letzten Sekunden async-bestätigter sync-Writes, aber ein SLOG-Mirror verhindert, dass ein einzelner SSD-Ausfall den ZIL-Schutz aushebelt.
Das fertige Layout sieht in zpool status und zpool list -v dann so aus:
Zum SLOG-Sizing noch ein Wort, weil es oft falsch gemacht wird. Der SLOG puffert nur die dirty data eines, maximal zweier txg-Flush-Intervalle. Bei vfs.zfs.dirty_data_max = 4 GiB reichen 16 GiB SLOG mit großzügigem Polster, mehr bringt schlicht nichts. Genauso wichtig: Der SLOG beschleunigt nichts direkt. Er ist nur ein schnelles, stromausfallsicheres Zwischenlager für den ZIL, greift ausschließlich bei synchronen Writes (fsync oder O_SYNC) und wird im Normalbetrieb nie gelesen, sondern erst nach einem Crash zum Replay. Wer das verwechselt, sollte sich die Trennung einprägen: Der ZIL ist immer da, das ist das Konzept. Der SLOG ist nur ein optionales separates Gerät dafür.
Die unbequeme Wahrheit: nur neue Metadaten wandern
Hier muss ich ehrlich sein, denn es ist der am häufigsten missverstandene Punkt. Ein special vdev migriert keine bestehenden Metadaten. Es nimmt nur auf, was nach dem Hinzufügen geschrieben wird. Alte Metadaten bleiben auf der HDD liegen, bis sie durch Copy-on-Write ohnehin neu geschrieben werden. Der volle Effekt entsteht also erst über die Zeit oder durch einen optionalen zfs send | zfs recv-Rebuild der großen Datasets. Kein Sofort-magisch-alles-schneller, sondern ein Mechanismus, der sich befüllt. Dass er sich befüllt, sieht man an der Belegung, die mit jedem neuen Metadaten-Write wächst:
special mirror-3 206G alloc 5.38G free 201G FRAG 27% CAP 2.60%
Die Messung danach, und wie man sie ehrlich liest
Jetzt kommt der Teil, an dem viele Tuning-Berichte unsauber werden, weil sie einen Vorher-Nachher-Durchsatz behaupten, der unter unterschiedlicher Last gemessen wurde und damit nichts beweist. Ich gehe einen anderen Weg und zeige die Wirkung über drei Argumente, von denen zwei komplett lastunabhängig sind.
Erstens der Latenz-Split pro vdev, das stärkste und lastunabhängige Argument. zpool iostat -lv zeigt die Latenzen getrennt pro vdev. Die folgende Tabelle sind seit-Boot-kumulierte Mittelwerte, also langzeit-repräsentativ und kein zufälliger Augenblick:
Die Kernaussage steht in zwei Zahlen: Metadaten-Leselatenz 455 µs auf der special-SSD gegen 46 ms auf der HDD, das ist etwa Faktor hundert. Jeder Metadaten-Zugriff, der nicht ohnehin aus dem RAM bedient wird, ist seitdem rund hundertmal schneller. Zu den -l-Spalten kurz: total_wait ist die Gesamtwartezeit inklusive Queue, disk_wait die reine Gerätelatenz, syncq_wait und asyncq_wait die Zeit in den ZFS-internen Queues. Wer ein echtes Zeitfenster statt des Boot-Mittels sehen will, nimmt zpool iostat -lv zroot 10 2 und liest das zweite Sample, denn das erste ist immer der Seit-Boot-Durchschnitt.
Zweitens die ARC-Metadaten-Aufteilung, also die Struktur des Workloads. Sie erklärt, warum es gerade hier so viel bringt:
arcstats.metadata_size = 17.4 GB # rund 85 % des ARC sind Metadaten
arcstats.data_size = 3.0 GB
arcstats.demand_metadata_hits = 1,133,349,218
arcstats.demand_metadata_misses = 11,594,376 # müssen auf Platte ... jetzt SSD
arcstats.demand_data_hits = 220,864,693
arcstats.demand_data_misses = 672,908
Der Workload ist metadaten-dominiert. Die Lifetime-ARC-Hit-Rate liegt bei rund 98,9 %, aber die über 11,5 Millionen Metadaten-Misses müssen zwangsläufig auf Platte, und sie landen jetzt auf SSD statt auf HDD. Hier multipliziert sich der Faktor-hundert-Latenzvorteil mit der schieren Menge an Metadaten-Operationen. Das ist die quantitative Begründung dafür, warum ausgerechnet ein special vdev der wirksamste Hebel war und nicht etwa nur mehr ARC. Begleitend habe ich das ARC-Limit von 16 auf 24 GiB angehoben, weil RAM frei war. Die Folge war eine Hit-Rate von rund 95 % auf rund 99 %. Zwei Hebel, die zusammenwirken: weniger Misses überhaupt, und die verbliebenen sind jetzt SSD-schnell.
Das Herzstück: die 8,5-MB/s-Rechnung
Drittens, und das ist der eigentliche Aha-Moment, eine logische Schlussfolgerung statt eines Durchsatz-Vergleichs. Die Ausgangsmessung lief unter einer ganz konkreten Last: Ein Client lud zeitgleich größere Dateien herunter, ein klassischer Datei-Download. Der Netzdurchsatz dabei lag bei rund 68 Mbit/s, also etwa 8,5 MB/s. Und genau hier wird es interessant.
Eine einzelne 7200-rpm-HDD liefert sequenziell 150 bis 200 MB/s. Ein Download mit 8,5 MB/s ist also kaum 5 % dessen, was eine Platte im Schlaf kann, und hier zogen sogar zwei davon im Mirror mit. Trotzdem zeigte die Messung, dass der HDD-Mirror im Mittel rund 60 % ausgelastet war und in 16 % der Messintervalle voll gesättigt (95 % Busy oder mehr), mit Latenzen bis 20 bis 24 ms.
Das ist ein Widerspruch, und der Widerspruch ist der Beweis. Für sequenzielle 8,5 MB/s darf eine HDD niemals an die Sättigung kommen. Wenn sie es doch tut, dann waren diese Zugriffe nicht sequenziell, sondern seek-gebunden. Die Köpfe wurden permanent quer über die Platte gerissen. Wofür? Für das, was dieses System zu rund 85 % beschäftigt: Metadaten-Random-I/O, also dnodes, indirekte Blöcke und Verzeichnis-Lookups, die sich auf denselben zwei Spindeln mit dem Download um die Köpfe prügelten, verschärft durch die damals hohe Fragmentierung. Ein eigentlich harmloser Download zerfiel so in ein Seek-Gewitter.
Genau diese Konkurrenz wurde mit dem special vdev eliminiert. Die Metadaten-Zugriffe laufen jetzt auf den SSDs mit rund 455 µs statt zig Millisekunden. Die HDD-Köpfe können auf dem Datenstrom bleiben, statt ständig für Metadaten wegzuspringen. Derselbe Download belastet die Spindeln damit nur noch einen Bruchteil. Nicht, weil die Dateidaten schneller kämen, die liegen weiter auf HDD, sondern weil der Lärm daneben weg ist. Diese Schlussfolgerung steht ohne erfundenen Vergleich, sie ist wasserdicht: 8,5 MB/s sättigt physikalisch keine HDD, also waren es Seeks, also Metadaten-Kontention, und genau die habe ich verlagert.
Wie sich der Pool im ruhigen Normalbetrieb anfühlt, zeigt eine zweite, entspannte Momentaufnahme. Sie ist ausdrücklich kein Vorher-Nachher-Vergleich, sondern nur ein Blick auf den Alltag:
CPU idle 96.9 % ARC hit 99.7 % ARC 23.3 GiB
HDD busy ~2 % HDD-Sättigung 0 % HDD-Latenz ~1.5 ms
SSD busy 4.3 % / 4.5 % (gleichmäßig über beide Mirror-Member)
net-out-Peak 2.0 Mbit/s
Im ruhigen Normalbetrieb langweilt sich der HDD-Mirror, fast alle Reads kommen aus ARC oder SSD. Das illustriert den Alltag. Die eigentliche Wirkung des Umbaus zeigen aber die 8,5-MB/s-Rechnung oben sowie der Latenz-Split und die ARC-Aufteilung, und die gelten unabhängig von der Last.
Sicherheit und Verschlüsselung, die entscheidende Nuance
Die wichtigen Datasets dieses Systems sind nativ mit ZFS verschlüsselt (encryption = aes-256-gcm), die System- und Boot-Datasets nicht. Sobald man ein special vdev einführt, stellt sich sofort die sicherheitskritische Frage: Landet jetzt unverschlüsselter Klartext auf den SSDs, nur weil dort die Metadaten liegen? Die Antwort ist ein klares Nein, und die Begründung ist wichtig genug, um sie sauber auszuführen.
ZFS native encryption verschlüsselt Dateiinhalte und die sensiblen Objekt-Metadaten, also Dateinamen, Verzeichnisstruktur, dnodes, Attribute und ACLs. Diese Blöcke sind bereits Ciphertext, bevor der Allocator überhaupt entscheidet, auf welches vdev sie wandern. Ein special vdev ist nur ein anderer Ablageort und ändert an der Verschlüsselung nichts. Verschlüsselte Metadaten bleiben auf der special-SSD verschlüsselt.
Was ZFS-Encryption ohnehin nicht verbirgt, special vdev hin oder her, sind die Metadaten auf Pool- und Dataset-Ebene: Dataset-Namen, Pool-Struktur, Anzahl und Größe von Snapshots, die Blockpointer-Struktur. Das ist eine Eigenschaft von ZFS-Encryption und keine neue Schwäche durch das special vdev.
aes-256-gcm ist authenticated encryption (AEAD), liefert also Vertraulichkeit und gleichzeitig Integritäts- und Authentizitätsschutz der verschlüsselten Blöcke.
Ein schöner Praxisbezug schließt sich hier zum Umbau-Kapitel: Genau weil verschlüsselte Datasets im Spiel sind, blockierte das zpool remove mit der Meldung über das Mounten verschlüsselter Datasets zum Replay. Das zeigt anschaulich, wie tief Encryption in den ZIL- und SLOG-Pfad eingreift, denn der ZIL kann Einträge für verschlüsselte Datasets enthalten, die sich nur nach dem Entsperren abspielen lassen. Das Fazit zur Sicherheit ist damit eindeutig: Ein special vdev ist verschlüsselungs-neutral. Wer verschlüsselte Datasets nutzt, bekommt verschlüsselte Metadaten auf der special-SSD, kein Klartext-Leak.
Abwägung: Vorteile, Nachteile, Risiken
Was unterm Strich für das special vdev spricht:
Es adressiert den gemessenen Engpass, Metadaten-Random-I/O, direkt an der Wurzel.
Es nutzt vorhandene SSDs, also keine Neuanschaffung, kein Pool-Neuaufbau, live im laufenden Betrieb hinzugefügt.
Rund hundertfach niedrigere Metadaten-Leselatenz (455 µs gegen 46 ms), spürbar bei ls, stat, find, Snapshots, Scrub und allen Workloads mit vielen kleinen Dateien.
Über special_small_blocks später fein justierbar, um kleine Datenblöcke nachzuziehen, ohne Downtime und nur für neue Writes.
Verschlüsselungs-neutral.
Der I/O verteilt sich jetzt gleichmäßig über beide Mirror-Member. Vorher lag eine SSD als einzelner SLOG bei rund 42 % Busy, die andere als L2ARC quasi brach.
Und ehrlich auch die andere Seite, denn ein special vdev ist kein Selbstläufer:
Redundanz ist Pflicht, nicht Kür. Ein nicht-redundantes special vdev bedeutet Totalverlust des Pools bei SSD-Ausfall. Mirror ist zwingend.
Keine Migration bestehender Metadaten. Nur neue Writes wandern, der volle Effekt kommt erst per send und recv-Rebuild.
Das special vdev kann volllaufen. Ist es voll, fallen neue Metadaten auf das langsame Daten-vdev zurück. Das ist kein Fehler, aber der Effekt lässt nach, also Füllgrad mit zpool list -v überwachen.
special_small_blocks zu hoch gesetzt verstopft das special vdev mit Datenblöcken und lässt es schneller volllaufen. Vorsichtig hochtasten (von 0 über 4K und 8K bis vielleicht 32K) und dabei den Füllgrad beobachten.
Mehr vdevs bedeuten mehr Komplexität und mehr Teile, die ausfallen können. Den SSD-Wear im Blick behalten, hier bewusst Datacenter-SSDs mit Power-Loss-Protection gewählt, weil sie Dauerlast und sync-Writes aushalten.
Der zpool remove-Stolperstein mit verschlüsselten Datasets gehört dokumentiert, damit man im Ernstfall nicht in Panik gerät.
Die eigentliche Botschaft
ZFS erlaubt es, die Storage-Architektur inkrementell und im laufenden Betrieb an einen gemessenen Engpass anzupassen, ohne Pool-Neuaufbau, ohne Downtime, ohne Datenmigration als Vorbedingung. Aus einem simplen HDD-Mirror wurde durch zwei zpool add-Befehle ein hybrider, mehrstufiger Pool: kalte Massendaten auf günstigen Spindeln, heiße Metadaten und optional kleine Blöcke auf schnellen SSDs, synchrone Writes über einen gespiegelten SLOG. Diese Flexibilität, tiered storage als Live-Operation, kombiniert mit Checksumming, Compression, Snapshots und nativer Verschlüsselung im selben Dateisystem, ist der eigentliche Kern. Man kauft sich SSD-Speed genau dort, wo die Messung den Schmerz zeigt, und lässt den Rest günstig auf HDD. Kein anderes verbreitetes Dateisystem macht das so geradlinig.
Ausblick
special_small_blocks schrittweise anheben, um kleine Dateien und nicht nur Metadaten auf SSD zu ziehen, live und nur für neue Writes.
Ein optionaler send und recv-Rebuild der großen Datasets, um bestehende Metadaten auf das special vdev zu migrieren und so den vollen Effekt zu heben.
Eine lastgleiche Wiederholungsmessung in einem Hochlast-Fenster für eine saubere Zahl auf der Schreibseite.
Spickzettel
Die Befehle, mit denen man Layout, Latenzen und ARC-Komposition selbst nachsieht:
# Pool-Layout und Auslastung pro vdev
zpool status zroot
zpool list -v zroot
# Latenzen pro vdev (das Geld-Kommando), 2. Sample lesen für ein echtes Zeitfenster:
zpool iostat -lv zroot 10 2
# ARC: Größe und Metadaten/Daten-Split plus Demand-Hits und -Misses
sysctl kstat.zfs.misc.arcstats.size kstat.zfs.misc.arcstats.metadata_size kstat.zfs.misc.arcstats.data_size kstat.zfs.misc.arcstats.demand_metadata_hits kstat.zfs.misc.arcstats.demand_metadata_misses
# allocation_classes-Feature und special_small_blocks
zpool get feature@allocation_classes zroot
zfs get special_small_blocks zroot
# Verschlüsselungs-Status der Datasets
zfs get encryption,keystatus DATASET
# SSD-Partitionierung
gpart show ada2 ada3
# SLOG-Sizing-Kontext
sysctl vfs.zfs.dirty_data_max
# Pool-Historie (zeigt die echten add/remove-Befehle)
zpool history zroot
Selbst einen HDD-Pool mit einem special vdev entschärft, oder noch am Abwägen, ob sich der Umbau lohnt? Erzähl mir gern von deinem Layout, oder stell deine fragen.
Vor einigen Wochen habe ich Nginx hier auf X25519MLKEM768 umgestellt und den Weg dorthin in einem eigenen Beitrag dokumentiert: Post-Quantum TLS für Nginx auf FreeBSD 15. Am Ende des Beitrags stand ein kleines Versprechen. Ich erweitere das Logging um die ausgehandelte Key-Exchange-Gruppe, lasse das ein paar Wochen laufen und werte dann aus, wer was tatsächlich spricht. Das ist jetzt eingelöst.
Der Messaufbau, in zwei Zeilen
Nginx kennt die Variable $ssl_curve. Die ist seit Ewigkeiten verfügbar und liefert pro Verbindung zurück, welche Kurve bzw. Gruppe beim TLS-Handshake benutzt wurde. Also X25519MLKEM768, X25519, secp384r1, prime256v1 und so weiter. Im Log-Format einfach nach $ssl_cipher eingehängt, einen Reload in den Nginx geschickt, fertig.
Nach rund 15 Tagen liegen grob 180.000 HTTPS-Handshakes im Log, verteilt über alles, was so an HTTPS-Clients vorbeikommt. Das ist genug Masse, um ein paar belastbare Muster zu sehen, ohne dass einzelne Ausreißer das Gesamtbild kippen. Exakte Besucherzahlen werde ich in diesem Beitrag bewusst nicht auflisten, das ist auch nicht das Thema. Mich interessiert die relative Verteilung. Wer macht PQ, wer macht es nicht?
Die eine Zahl vorweg
Über alle HTTPS-Verbindungen (Browser, Bots, Crawler, Monitore, alles) sieht die Verteilung der Kurven so aus:
Klingt erstmal ordentlich. 57 % PQ über das komplette Gemisch, 98 % TLS 1.3, lediglich 2 % TLS 1.2. Aber in dieser Zahl stecken ein paar Dinge versteckt drin, die man erst sieht, sobald man die User-Agents grob auseinandersortiert. Ich habe das in ein paar Kübel geworfen: Browser, AI-Crawler, klassische Suchmaschinen, SEO-Spider, Fediverse-Software, RSS-Reader, Monitore und CLI-Tools. Nicht perfekt, aber brauchbar.
Browser, die Post-Quantum-Avantgarde
Bei echten Browsern (Chrome, Firefox, Safari, Edge) sieht es deutlich freundlicher aus. Zusammengenommen sprechen rund 77 % der Browser-Verbindungen bereits MLKEM768. Aufgeschlüsselt:
Firefox liegt erkennbar vorn. Nicht weltbewegend weit, aber deutlich. Mozilla hat MLKEM768 früh aktiviert und nutzt standardmäßig die Hybrid-Gruppe, wenn der Server sie anbietet. Chrome hängt etwas hinter den anderen und ich vermute, das liegt an den ganzen älteren Chrome-Builds, die in Embedded-Devices, WebViews und seltsamen Apps stecken und auch als Chrome im User-Agent stehen. Opera dagegen nimmt fast immer nur klassisches X25519. Keine Ahnung warum, schaue ich mir vielleicht mal gesondert an.
Das Schöne daran ist: Ich habe für diese 77 % exakt nichts getan außer die Nginx-Konfiguration anzupassen. Kein Opt-in, kein Banner, keine Weiche. X25519MLKEM768 steht ganz oben in ssl_ecdh_curve und wird genommen, wenn der Client es kann. Der Rest ist reines Client-Upgrade-Verhalten. Das ist eigentlich die schönste Erkenntnis aus der ganzen Auswertung. Wenn die Serverseite rechtzeitig aktualisiert wird, zieht die Clientseite fast geräuschlos nach.
AI-Crawler, flächendeckend bei null
Jetzt kommt der Teil, den ich so nicht erwartet hätte. Die großen AI-Crawler holen sich hier regelmäßig Inhalte ab (siehe auch von SEO zu AEO: llms.txt und llms-full.txt), der TLS-Stack dahinter ist bei praktisch allen auf dem Stand von vor zwei Jahren:
Amazon ist nochmal ein eigenes Kapitel. Amazonbot kommt hier ausschließlich mit TLS 1.2 und secp384r1. Das ist ein TLS-Stack, den ich persönlich bei einem Unternehmen, das Cloud-Sicherheit verkauft, nicht mehr erwartet hätte. Aber Messungen lügen nicht.
Auf diese zwei hätte ich nicht gesetzt. TikToks Crawler macht zu über 90 % PQ, DuckDuckGos AI-Helper zu 100 %. Wer hätte das gedacht.
Klassische Suchmaschinen, auch nicht besser
Bei den traditionellen Suchmaschinen ist das Bild fast identisch zum AI-Lager:
Googlebot 0 % PQ
Applebot 0 % PQ
PetalBot 0 % PQ
Baiduspider 0 % PQ
SeznamBot 0 % PQ
Qwant 0 % PQ
YandexBot 1 % PQ
Bingbot 0 % PQ und zu 99,6 % auf TLS 1.2 + secp384r1
DuckDuckBot 84 % PQ <- der einzige helle Fleck
Besonders bitter: Bingbot. Der läuft hier praktisch ausschließlich auf TLS 1.2 mit secp384r1. Microsofts Produktions-Webcrawler, 2026, mit einem TLS-Stack, den Webauditoren seit Jahren rot anstreichen. Googlebot ist immerhin auf TLS 1.3, aber halt ohne PQ. Der einzige, der fürs Thema etwas tut, ist DuckDuckBot. Respekt dafür.
SEO-Spider, am weitesten hinten
Der traurigste Haufen. AhrefsBot, SemrushBot, MJ12bot, DotBot, Barkrowler, DataForSeoBot, SeekportBot, Vebidoobot, alle zwischen 0 % und 3 % PQ. Vebidoobot kommt komplett auf TLS 1.2 rein. Das sind kommerzielle Produkte, die von Seitenbetreibern dafür bezahlt werden, Webseiten zu analysieren und Empfehlungen auszusprechen. Analysieren tun sie mit einem TLS-Stack, den sie ihren eigenen Kunden vermutlich als kritischen Finding in den Bericht schreiben würden. Kurios.
Fediverse, alles drin je nach Codebase
Seit dem Anschluss ans Fediverse ist das hier die zweitgrößte Traffic-Quelle nach den Browsern, deshalb habe ich mir das extra angeschaut. Mastodon stellt davon den Löwenanteil, weil es schlicht die meisten Instanzen gibt:
Bei Mastodon gibt es eine große Varianz zwischen den Instanzen, weil die Ruby- und OpenSSL-Version des jeweiligen Server-Hosts entscheidet, ob PQ geht. Aktuelle Distribution mit OpenSSL 3.5 oder neuer: dabei. Noch auf OpenSSL 3.0 festhängend: nicht dabei. Der Schnitt liegt bei 62 %, was für ein so diverses Ökosystem schon erstaunlich ordentlich ist.
snac und GoToSocial liegen deutlich höher, weil sie in Go beziehungsweise C geschrieben sind und moderne TLS-Stacks mitbringen. Akkoma und Pleroma (beide Elixir/Erlang) zeigen dagegen gar keine PQ-Adoption. Das hängt an der OpenSSL-Version, die die BEAM-VM dort nutzt. Misskey und die ganzen Forks dazwischen liegen bei rund einem Drittel.
RSS-Reader, unerwartet modern
Hätte ich vorher schätzen sollen, hätte ich RSS-Reader eher am Ende dieser Liste verortet. Alte Technologie, alte Software, wahrscheinlich alter TLS-Stack. Stimmt aber nicht:
Miniflux macht 100 % PQ, weil es in Go geschrieben ist und ab Go 1.24 MLKEM768 standardmäßig im TLS-Stack sitzt. FreshRSS und NextCloud-News laufen meist auf aktuellen PHP/curl-Umgebungen und ziehen MLKEM darüber mit. Feedly als kommerzieller Anbieter: 0 %. Also genau das Gegenteil dessen, was ich erwartet hätte. Self-Hosted ist hier eindeutig moderner unterwegs als die SaaS-Variante.
CLI-Werkzeuge und Kuriositäten
go-http-client 93 % PQ <- Go 1.24+
curl 30 % PQ <- je nach OpenSSL-Build
python-requests 16 % PQ
Node (axios) 61 % PQ
okhttp 0 % PQ
wget 0 % PQ
Twitterbot 97 % PQ <- unerwartet weit vorn
Das Muster ist eigentlich immer dasselbe. Der TLS-Stack der Laufzeitumgebung entscheidet. Go ≥ 1.24 macht es automatisch, moderne Node-Versionen bringen einen aktuellen OpenSSL mit, Python und curl hängen an der Distribution. okhttp auf Android und wget: Fehlanzeige.
Kleiner Spaß am Rande. Twitterbot ist zu 97 % auf MLKEM. Also ausgerechnet der Link-Preview-Crawler von X/Twitter ist moderner unterwegs als alle anderen Social-Preview-Bots zusammen. WhatsApp: 4 %, Discord: 0 %, LinkedIn ist kaum vertreten. Warum Twitter? Keine Ahnung. Vermutlich ein moderner Go-Client unter der Haube.
TLS 1.2, wer hängt noch ganz unten?
2 % des Traffics kommen komplett mit TLS 1.2, also ohne jede Chance auf PQ. Die Top-Kandidaten sind:
vebidoobot (SEO-Spider)
Amazonbot
DotBot, MJ12bot (SEO)
theoldreader.com (RSS-SaaS)
http.rb/Mastodon auf aelteren Instanzen
ein paar vereinzelte Alt-Browser und Skype-Link-Previews
Also fast ausschließlich kommerzielle Crawler, deren TLS-Library vor der ganzen 1.3-Welle kompiliert wurde, und ein paar ältere Mastodon-Instanzen. Reale Leser sind so gut wie nicht betroffen. Wer heute einen Feed-Reader mit TLS 1.2 nutzt, hat vermutlich andere Probleme zuerst zu lösen.
Gibt es einen Trend in den 15 Tagen?
Nicht wirklich. Der PQ-Anteil pro Tag schwankt zwischen 46 % und 65 %, je nach Traffic-Mix (mehr Browser an Wochentagen, mehr Crawler nachts und am Wochenende). Einen klaren Aufwärts- oder Abwärtstrend gibt es nicht. Wir sind im Plateau. Die Browser haben den Sprung gemacht, der Rest der Welt noch nicht. Der nächste Sprung kommt, wenn die großen Crawler-Betreiber ihre Go-, Python- oder Node-Stacks aktualisieren oder OpenSSL 3.5+ in den gängigen Distributionen ankommt. Bei Debian Trixie, RHEL 10 und Ubuntu 26.04 sollte das passieren, dann reden wir in einem Jahr nochmal.
Was ich daraus mitnehme
Echte Besucher sind bei MLKEM768 schon sehr weit. Browser-Entwicklung funktioniert erstaunlich gut.
AI- und SEO-Crawler sind deutlich hinter dem, was man erwarten würde. Cutting Edge in der Marketing-Abteilung, Uralt-Stack im Maschinenraum.
Fediverse-Software ist so divers wie ihre Codebasen. Bei Mastodon entscheidet die Instanz, nicht die Software.
RSS-Reader sind unerwartet modern unterwegs. Self-Hosted schlägt SaaS auch hier.
Am Ende hängt fast alles an der OpenSSL-Version unter der Anwendung. Wieder mal.
Bingbot auf TLS 1.2 ist 2026 trotzdem noch bemerkenswert.
Was mich am meisten gefreut hat: Ich habe keinerlei Reibungsverluste gesehen. Kein Client ist wegen PQ gestolpert, keine Verbindung ist fehlgeschlagen, die vorher funktioniert hätte. Der Handshake wählt einfach die beste gemeinsame Gruppe und gut ist. Deshalb nochmal der Appell an alle, die den Einstellungs-Beitrag noch vor sich haben: Das ist wirklich ein Zweizeiler. Macht es einfach.
Nächster Check in ein paar Monaten
Ich lasse das Logging weiterlaufen und schaue in ein paar Monaten nochmal rein. Was mich besonders interessiert:
Wann machen OpenAI, Anthropic und Meta ihren Crawler modern? Bleibt das auf Jahre bei 0 % PQ, oder kommt da plötzlich ein Sprung?
Schafft es OpenSSL 3.5 in die nächsten Long-Term-Release-Linuxe, und wie schnell ziehen Mastodon-Instanzen nach?
Springt Googlebot irgendwann auf PQ um? Bisher nein. Wenn das kommt, dürfte das unmittelbar sichtbar sein.
Kommt TLS 1.3 für Amazonbot? Zumindest das wäre ein Anfang.
Update Juli 2026: Was hinter der Gruppe X25519MLKEM768 steckt, die hier in rund 57 Prozent der Handshakes auftaucht, zerlege ich in einem eigenen Beitrag: X25519MLKEM768 zerlegt.
Im letzten Beitrag zum Thema hatte ich angekündigt, dass ich mir auch den NB-2033-U vornehmen will. Der steckt in einem zweiten Fujitsu Notebook hier, dem von meiner Tochter Maja. Gleicher Hersteller, gleiche Sensorfamilie, sollte ähnlich laufen wie beim NB-2020-U. Dachte ich.
Falsch gedacht.
Hersteller sagt: geht nicht
Ich hatte bei NEXT Biometrics nach Protokolldokumentation oder einem SDK für den NB-2033-U gefragt. Kevin Hung, Director FAE, antwortete freundlich aber eindeutig:
„Both 2020-U and 2033-U have different firmware and USB stack. The code flow (libusb) related to 2033-U and 2020-U is different. This could be the reason for 2033-U failure/unsupported in linux. As of now, it is not supported.“
Kein SDK, keine Doku, kein Support. Und 74 Einträge auf linux-hardware.org mit Status „failed“ für die USB ID 298d:2033. Weltweit kein Linux-Support für dieses Gerät.
Gut. Dann eben Reverse Engineering.
Erster Versuch: Windows-Treiber belauschen
Plan A war klassisch: Windows-Treiber in einer VM laufen lassen, USB-Traffic mitschneiden. VirtualBox installiert, USB-Passthrough konfiguriert, Windows gestartet. Der Fingerabdruckleser tauchte im Gerätemanager auf. Mit Code 31. Treiber konnte das Gerät nicht starten. Secure Boot hatte VirtualBox den Kernel-Treiber nicht signiert, und der USB-Passthrough war damit unbrauchbar.
Plan A verworfen.
Plan B: Das SDK direkt auf Linux
Das SDK von NEXT Biometrics (libNBBiometrics.so) unterstützt den NB-2033-U intern. Es kommuniziert direkt über libusb, ohne Kernel-Treiber. Das heißt: ich kann das SDK-Sample direkt auf dem Linux-Notebook laufen lassen und gleichzeitig den USB-Traffic mit usbmon mitschneiden.
Dafür musste Secure Boot deaktiviert werden. usbmon ist ein Kernel-Modul, und lockdown=integrity (von Secure Boot gesetzt) blockiert es auch für root. Secure Boot im BIOS aus, lockdown=none in GRUB, Neustart. Danach:
7654 Zeilen USB-Traffic. Das komplette Protokoll des NB-2033-U, aufgezeichnet während einer Enrollment-Session.
Was dabei rauskam
Das Protokoll ist komplett anders als beim NB-1010-U/NB-2020-U. Kevins Aussage stimmte. Hier die wesentlichen Unterschiede:
Eigenschaft
NB-1010-U / NB-2020-U
NB-2033-U
Bulk IN Endpoint
EP 3 (0x83)
EP 1 (0x81)
Kommandoformat
[0x80][CMD][SEQ][0x00]...
[CMD][0x00][LEN_LO][LEN_HI][PAYLOAD] (TLV)
Finger-Erkennung
Einzelnes 0x38
Zwei 0x0D Config + 0x38
Bildübertragung
90 Chunks à 540 Bytes
180 Chunks à 268 Bytes
Init
Einmal 0x07
Zweimal 0x07 nötig
Gleicher Sensor-Die (256×180 Pixel, 385 DPI, aktiv thermisch), aber ein komplett anderer USB-Stack. Der NB-2033-U nutzt ein TLV-Format (Type-Length-Value) statt des festen Kommandoschemas vom NB-1010-U. Jedes Kommando hat eine eigene Längenangabe, und die Antworten sind anders strukturiert.
Die Kommandos im Detail
Aus dem USB-Capture konnte ich sechs Kommandos identifizieren:
0x07 — Init/Wake. Muss zweimal gesendet werden, sonst reagiert der Sensor nicht.
0x0D — Sensor-Konfiguration. Wird zweimal vor jeder Finger-Erkennung gebraucht, um den „Enhanced“ Modus zu aktivieren.
0x38 — Finger-Erkennung. Byte 4 der Antwort ist der Detect-Level. Schwellwert 40.
Der Sensor misst Temperaturänderungen, nicht statischen Kontakt. Das klingt nach einem Detail, ist aber für die Treiber-Implementierung entscheidend. Finger auflegen erzeugt einen kurzen Spike im Detect-Wert (10 bis 50+). Finger bleibt liegen, und der Wert fällt zurück auf Basisniveau. Der Treiber muss also den Spike erkennen, nicht einen dauerhaften Zustand.
Dazu kommt: Nach dem Init gibt es transiente Spikes, die ungefähr 1,5 Sekunden brauchen, bis sie abklingen. Ohne Settle-Pause nach dem Init erkennt der Treiber Phantom-Finger.
Der Treiber
Rausgekommen ist nb2033.c, ein eigenständiger libfprint-Treiber mit rund 350 Zeilen. Kein proprietärer Code, keine SDK-Abhängigkeit. Das SDK diente nur als Referenz für die Capture-Analyse, der Treiber ist sauber von Grund auf geschrieben. Lizenz: LGPL 2.1+ wie alle libfprint-Treiber.
Der Merge Request ist eingereicht: MR !574 bei libfprint. Fünf Dateien: der neue Treiber, meson.build, autosuspend.hwdb und die Allowlist. CI läuft durch. Der verwandte MR !569 für den NB-2020-U ist noch in Review.
Für die Wiki-Aktualisierung (das Gerät von der „unsupported“ Liste nehmen) gibt es Issue #134.
Fazit
Der Hersteller sagt „not supported“, 74 Linux-User melden „failed“, und trotzdem war das an einem Nachmittag erledigt. SDK auf Linux ausführen, USB-Traffic mitschneiden, Protokoll rekonstruieren, Treiber schreiben, testen, upstream einreichen. Alles mit Open-Source-Tools: usbmon, libusb, libfprint.
Das Ergebnis: Majas Notebook hat jetzt einen funktionierenden Fingerabdruckleser unter Linux. Und sobald der Merge Request durch ist, haben ihn alle anderen auch.
Wir haben 2026. Alles wandert in die Cloud. Trotzdem will ich heute über serielle Konsolen schreiben. Klingt retro, ist es aber nicht. Wenn ein Switch sich verkonfiguriert hat und das Netzwerk weg ist, hilft kein Ansible und kein Dashboard in der Cloud. Dann hilft nur noch der serielle Konsolenport. Out-of-Band Management ist nicht tot. Es wurde nur teuer verpackt.
Kommerzielle Konsolenserver kosten gerne vierstellig. Oder man nimmt einen Raspberry Pi der noch herum liegt und auf eine neue Aufgabe wartet (ich habe hier ein paar Pi1 oder 2 herum liegen). Zusammen mit zwei USB Serial Adaptern hat man für unter 50 Euro einen Konsolenserver mit acht Ports. Das reicht für die meisten Setups locker aus.
Wofür ein Konsolenserver
Der klassische Fall: Ein paar Switches im Rack, jedes Gerät hat einen seriellen Konsolenport. Im Normalbetrieb konfiguriert man über das Netzwerk. Aber wenn mal eine falsche Route das Management Interface unerreichbar macht oder ein VLAN Umbau schiefgeht, steht man vor dem Gerät und steckt ein Kabel rein. Wenn das im DC in Frankfurt ist, oder vielleicht irgendwo in China, dann kann das spannend werden.
Oder man hat vorgebaut.
Ein Konsolenserver hängt permanent an den seriellen Ports der Netzwerkgeräte. Man kommt per SSH auf den Konsolenserver und von dort auf die serielle Konsole des Zielgeräts. Ob das Netzwerk funktioniert oder nicht, spielt keine Rolle mehr. Öhm also ja, so grob. Der Pi sollte dann ja schon noch erreichbar sein. Aber man hat ja in einem entfernten DC auch eine Dailin Line oder ähnliches, richtig? Richtig?
Hardware
Ein Raspberry Pi. Es muss kein aktuelles Modell sein. Selbst ein alter Pi 2 reicht völlig aus. Das Ding muss ser2net laufen lassen und ein paar serielle Ports bedienen, dafür braucht man keinen Quad Core mit 8 GB RAM. Der Pi aus der Schublade bekommt endlich eine sinnvolle Aufgabe.
FTDI Quad Port USB Serial Adapter (Vendor 0403, Product 6011). Pro Adapter bekommt man vier serielle Ports. Mit zwei Adaptern hat man acht Ports. Die Dinger gibt es für kleines Geld.
RS232 Kabel zu den Console Ports der Netzwerkgeräte. Welcher Stecker passt, hängt vom Hersteller ab. RJ45 auf DB9, DB9 auf DB9, die üblichen Verdächtigen. Da muss man schauen was die eigenen Geräte mitbringen.
Stabile Gerätenamen mit udev
Das erste Problem nach dem Einstecken der USB Adapter: Linux vergibt die /dev/ttyUSBx Nummern nach Lust und Laune. Nach einem Reboot kann ttyUSB0 plötzlich ttyUSB4 sein. Wenn man wissen will welcher Port an welchem Gerät hängt, ist das unpraktisch.
Die Lösung sind udev Regeln. Jeder FTDI Adapter hat eine eigene Seriennummer. Die findet man so:
udevadm info -a -n /dev/ttyUSB0 | grep serial
Damit baut man sich Regeln die stabile Symlinks erzeugen. Datei /etc/udev/rules.d/99-serial-consoles.rules:
FT000001 und FT000002 ersetzt man durch die echten Seriennummern der eigenen Adapter. Das Ergebnis sind stabile Symlinks: /dev/quad0-00 bis /dev/quad0-03 für den ersten Adapter, /dev/quad1-00 bis /dev/quad1-03 für den zweiten. Acht Ports, immer gleich benannt. Egal wie oft man den Pi neustartet.
ser2net
ser2net bildet die seriellen Ports auf TCP Ports ab. Man kann dann per Telnet auf einen bestimmten Port zugreifen und landet direkt auf der seriellen Konsole des zugehörigen Geräts. Installieren mit apt install ser2net, dann die Konfiguration in /etc/ser2net.conf:
9600 8N1 ist der Standard bei den meisten Netzwerkgeräten. Falls ein Gerät eine andere Baudrate braucht, passt man die entsprechende Zeile an. Der Timeout von 600 Sekunden trennt die Verbindung nach zehn Minuten Inaktivität. Das verhindert dass ein vergessenes Telnet die Konsole dauerhaft blockiert.
Direkter Zugriff mit minicom
Wer ser2net nicht nutzen will oder schnell direkt auf einen Port muss, nimmt minicom:
minicom -D /dev/quad0-00 -b 9600
minicom ist gut für schnelle Tests und Debugging. Für den Dauerbetrieb mit mehreren Ports gleichzeitig ist ser2net die bessere Wahl.
Warum localhost
ser2net ist im gezeigten Setup bewusst auf localhost gebunden. Man muss sich erst per SSH auf den Pi einloggen und dann telnet 127.0.0.1 200x aufrufen. Das ist Absicht.
Man könnte ser2net auch auf 0.0.0.0 binden und die Ports direkt aus dem Netz erreichen. Davon rate ich ab. Telnet ist unverschlüsselt. Auch in einem Management VLAN hat das nichts verloren.
Bessere Alternativen wenn man ohne SSH auf den Pi will:
ser2net ab Version 4.x unterstützt SSL/TLS. Damit hat man verschlüsselte Verbindungen direkt zu den Console Ports.
stunnel vor ser2net schalten. stunnel terminiert TLS und reicht die Verbindung an den lokalen ser2net weiter.
Wer nativen SSH Zugriff direkt auf die seriellen Ports braucht, sollte sich conserver anschauen. ser2net kann kein SSH.
Für die meisten Setups ist SSH auf den Pi und dann Telnet auf localhost der einfachste und sicherste Weg.
Absichern
Ein paar Dinge die man auf dem Pi noch machen sollte:
Den Default Benutzer pi löschen. Einen eigenen Benutzer anlegen. SSH Key Authentifizierung einrichten und Login per Passwort deaktivieren. Das ist nicht optional.
NTP konfigurieren. Timestamps in Logs sind nutzlos wenn die Uhrzeit nicht stimmt.
Syslog an einen zentralen Logserver weiterleiten. Wenn man serielle Konsolen mitschneidet, will man die Logs nicht nur lokal auf dem Pi haben.
Workflow
Der Alltag sieht dann so aus:
SSH auf den Pi: ssh admin@10.0.0.50
Telnet auf den gewünschten Port: telnet 127.0.0.1 2003
Man landet auf der seriellen Konsole von Switch 3
Alternativ direkt mit minicom: minicom -D /dev/quad0-02 -b 9600
Zum Trennen: Ctrl-] und dann quit bei Telnet. Ctrl-A gefolgt von X bei minicom.
Fazit
Ein alter Raspberry Pi, zwei USB Adapter, ein paar Kabel. Mehr braucht man nicht für einen funktionierenden Konsolenserver mit acht Ports. Die Einrichtung dauert vielleicht eine Stunde. Danach läuft das Ding und man muss nie wieder ein Konsolenkabel quer durch den Serverraum schleppen.
Und der alte Pi aus der Schublade hat endlich wieder eine Aufgabe.
Ihr habt Fragen, Anmerkungen oder baut das Setup selbst nach? Meldet euch gerne über die Kontaktseite oder direkt per E-Mail.