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

Kategorie: Kernel-Error-Blog (Seite 1 von 48)

Persönlicher Tech-Blog von Sebastian van de Meer — Beiträge zu IT-Security, Netzwerken, FreeBSD, Linux, Elektronik und Maker-Projekten.

Vier Monate von Bing nicht ausgeliefert: ein Schalter auf Domain-Ebene und 218 IndexNow-Einreichungen

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.

Visualisierung des Bing-Indexierungsproblems: Nach zahlreichen IndexNow-Einreichungen bricht die Sichtbarkeit der Domain abrupt ein und erholt sich Monate später wieder.

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.

MonatIndexKommentar
2025-0881normaler, langsam wachsender Verlauf
2025-0988
2025-10104
2025-11107
2025-12108Höchststand
2026-01105
2026-0291
2026-0344Einbruch am 17.03. mitten im Monat
2026-0424Teilerholung auf niedrigem Niveau
2026-0510zweiter Einbruch ab 16.05.
2026-067Tiefpunkt
2026-0755am 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:

PhaseImpressionsKlicksCTR
Baseline 01.11.2025 bis 16.03.2026 (136 Tage)1001002,75 %
17.03. bis 23.03.2026 (7 Tage)0,600 %
24.03. bis 15.05.2026 (53 Tage)21415,3 %
16.05. bis 08.07.2026 (54 Tage)6125,2 %
09.07. bis 05.08.2026 (28 Tage)69953,75 %
Stufendiagramm der Bing-Impressions und Klicks als Index gegen die Baseline 100. Am 17. März 2026 fällt die Kurve senkrecht auf nahezu null, bleibt vier Monate niedrig und springt am 9. Juli 2026 wieder nach oben.
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.

DatumEinreichungenZeitfenster (CEST)
11.02.20269311:35 bis 11:55
21.02.20261
09.03.20263
11.03.20266
12.03.202610
13.03.20263
14.03.20269809:33 bis 15:00
18.03.20264

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:

550 5.7.133 RESOLVER.RST.SenderNotAuthenticatedForGroup

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.

Siehe auch

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.

Intel QuickAssist 8950-SCCP: Krypto-Beschleuniger von 2013 gegen eine CPU von heute

Intel-QuickAssist-8950-SCCP-Krypto-Beschleuniger neben einer modernen Server-CPU im technischen Leistungsvergleich.

Manche Bauteile überleben ihren eigenen Sinn. Diese Karte liegt seit Jahren bei mir herum, hat einmal einen echten Job gemacht, und heute ist sie ein Stück Technikgeschichte mit Kühlkörper. Also habe ich sie in meine Workstation gesteckt, einfach um zu sehen, was sie noch kann und wie sie sich gegen eine aktuelle CPU schlägt. Das Ergebnis ist interessanter geworden als erwartet, aber nicht auf die Art, die ich erwartet hatte.

Die Vorgeschichte: irgendwann lief bei mir ein FreeBSD-Server mit einer Intel-Atom-CPU, und diese CPU hatte kein AES-NI. Auf so einer Maschine verschlüsselte Platten zu betreiben ist zäh. Also kam eine Intel QuickAssist Adapter 8950-SCCP rein, eine PCIe-Steckkarte, die genau das in Hardware macht. Und sie hat ihren Job gut gemacht: weniger CPU-Last, mehr Durchsatz am Storage. Später wanderte sie in einen älteren Xeon, immer noch FreeBSD, immer noch GELI. FreeBSD 14 hat sie produktiv nie gesehen.

Intel QuickAssist Adapter 8950-SCCP mit montiertem passiven Kühlkörper, Low-Profile-Karte mit PCIe-Steckkontakten und Slotblech
So hing sie im Server: Low Profile, PCIe x8, ein gerippter Aluklotz über fast der ganzen Platine. Lüfter gibt es keinen, den soll das Rack liefern.

Warum das damals überhaupt ein Problem war

Das ist der Teil, den heute fast keiner mehr auf dem Schirm hat: AES in der CPU war jahrelang keine Selbstverständlichkeit. AES-NI kam 2010 mit Westmere, aber Intel hat den Befehlssatz danach als Segmentierungsmerkmal benutzt. Bei vielen Atom-, Celeron- und Pentium-Modellen fehlte er, und zwar genau in den stromsparenden Storage- und Firewall-Plattformen, in denen man ihn am dringendsten gebraucht hätte. Ohne AES-NI rechnet eine CPU AES über Tabellen-Lookups, also grob eine Größenordnung langsamer, und obendrein mit Cache-Timing-Seitenkanälen, die man erst mit Aufwand wieder zubekommt.

Bei ARM war es lange genauso, und da ist es sogar noch besser zu greifen. ARMv7 hatte überhaupt keine AES-Befehle. Die Cryptography Extensions von ARMv8-A sind bis heute optional, ein Chiphersteller kann sie einfach weglassen. Der Raspberry Pi ist das schönste Beispiel: alle 64-Bit-fähigen Pis bis einschließlich Modell 4 haben keine AES-Beschleunigung, erst der BCM2712 im Pi 5 bringt sie mit. Wer sich mal gefragt hat, warum verschlüsselte Backups auf einem Pi 4 so quälend langsam sind: das ist der Grund, und es ist kein Softwareproblem.

Dazu kommt der zweite Teil der Geschichte. Verschlüsselung war lange die Ausnahme, nicht die Regel. Wer 2010 eine Webseite betrieben hat, hat HTTPS für den Loginbereich angeschaltet und sonst nirgends, weil es teuer war, weil Zertifikate Geld kosteten und weil TLS auf schwacher Hardware wirklich weh tat. Erst mit Snowden und vor allem mit dem Druck, den die Browserhersteller danach aufgebaut haben, kippte das ins Gegenteil. Plötzlich sollte alles verschlüsselt sein, und zwar sofort. In dieser Phase steckten in Loadbalancern und Reverse Proxies routinemäßig Offload-Karten, für Krypto oder für Kompression, weil die Allzweck-CPU dahinter das schlicht nicht mitgemacht hat. Diese Karte kommt aus genau dieser Zeit.

Was das für eine Karte ist

Auf dem Aufkleber steht INTEL(R) QUICK ASSIST ADAPTER 8950-SCCP, Bestellnummer IQA89501G1P5, Intel MM 929848, Herstelldatum 09/2017, Made in Malaysia. Der Chip darauf ist allerdings älter als die Karte: die Generation stammt aus Q4 2013.

Rückseite des Intel QuickAssist Adapter 8950-SCCP mit Typenschild, Bestellnummer IQA89501G1P5, Intel MM 929848 und Herstelldatum 09/2017
Die Rückseite mit dem Typenschild. Der blaue Aufkleber ist ein Echtheitsmerkmal von yottamark, dazu KCC-Zulassung, UL-Nummer und das PCI-Express-Logo.

SCCP steht für Single Coleto Creek PCIe. Coleto Creek ist der interne Codename, der Beschleunigerchip heißt DH895xCC, und Intel hat ihn als Intel Communications Chipset 8955 verkauft. Die PCI-ID ist 8086:0435. Bei Intel ist die Karte längst End of Life, abgekündigt zusammen mit ihrem Nachfolger 8960.

Die Datenblattwerte, also Herstellerangaben und nichts von mir Gemessenes:

AngabeWert
Bulk-Kryptobis 50 Gbit/s
Kompressionbis 24 Gbit/s
Host-InterfacePCIe Gen3 x8
SR-IOV1 Physical Function, 32 Virtual Functions
Leistungsaufnahmemaximal etwa 40 W
Umgebungstemperatur0 bis 55 Grad Celsius

Gebaut wurde das Ding nicht für Workstations, sondern für Telco und Netzwerk: VPN-Konzentratoren, IPsec-Gateways, TLS-Terminierung, Deduplizierungs-Appliances. 50 Gbit/s Bulk-Krypto in einer Low-Profile-Karte war 2013 eine Ansage. Mein Lieblingsdetail aus dem Featureset ist aber ein anderes: die Karte kann Kasumi und Snow 3G in Hardware, also die Mobilfunkchiffren aus 3G und LTE. Das ist so wunderbar spezifisch, dass man sofort weiß, in welchem Blech sie eigentlich stecken sollte, und das ist bestimmt kein Tower unter einem Schreibtisch.

Kühlkörper ab: zwei Chips, nicht einer

Jetzt wird es hübsch. Unter dem Kühlkörper sitzen nämlich nicht ein großer Chip, sondern zwei, und dazu eine Leerstelle.

Platine des Intel QuickAssist Adapter 8950-SCCP ohne Kühlkörper, links der nackte Coleto-Creek-Die, mittig der PLX PEX 8724 als PCIe-Switch, rechts eine unbestückte Lötfläche
Links U5, der Coleto Creek als Flip-Chip ohne Heatspreader. Mittig U1, der PLX PEX 8724. Rechts neben dem Slotblech die leere Fläche, um die es weiter unten geht.

Links auf Position U5 liegt der Coleto Creek als Flip-Chip-BGA ohne Heatspreader. Das nackte Silizium liegt frei, man schaut direkt auf den Die. Das sieht man heute selten und es ist genau der Grund, warum ich den Kühlkörper überhaupt abgeschraubt habe.

Mittig auf U1 sitzt aber ein Chip, den ich dort nicht erwartet hätte: ein PLX Technology PEX8724-CA80BC G, Fertigungscode 1703, also Kalenderwoche 3 in 2017, gefertigt in Taiwan. Das ist ein PCIe-Gen3-Switch mit 24 Lanes und 6 Ports. Auf einer Karte, die nur einen einzigen Beschleunigerchip trägt, ist ein Switch zunächst mal erklärungsbedürftig.

Rechts, direkt neben dem Slotblech, liegt dann noch eine komplett unbestückte BGA-Fläche. Ein vollständiges, leeres Lötpad-Raster in Chipgröße. Da war offensichtlich mal etwas geplant.

Nahaufnahme der unbestückten BGA-Lötfläche neben dem Slotblech des Intel QuickAssist Adapter 8950-SCCP, ein komplettes leeres Pad-Raster in Chipgröße
Der leere Platz aus der Nähe. Ein Platz in Chipgröße, alles drumherum bestückt, nur hier fehlt der Chip.

Wenn man Switch und Leerstelle zusammennimmt, geht eine Rechnung verdächtig glatt auf:

PEX 8724 = 24 Lanes

  x8  Upstream zum Slot
+ x8  zum bestückten Coleto Creek (U5)
+ x8  zu einem weiteren, unbestückten Platz
= 24

Und der Kernel liefert dazu tatsächlich einen passenden Befund: der Downstream-Port c4:08.0 des Switches existiert, hat LnkCap Width x8 und ist untrainiert, also LnkSta Width x0. Da hängt nichts dran.

Und hier muss ich mich selbst bremsen, denn die Versuchung ist groß. Belegt ist nur: in der Switch-Konfiguration sind diesem Port acht Lanes zugewiesen, und es hängt nichts daran. Nicht belegt ist, dass diese acht Lanes zu dem leeren Platz führen. Das könnte genauso ein nie herausgeführter Port sein oder für etwas völlig anderes gedacht gewesen sein. Die Lötfläche macht die Deutung attraktiv, und mehr ist es nicht. Wer es wirklich wissen will, müsste die Lanes am Board durchmessen oder das EEPROM des PEX8724 auslesen und dort in die Port Configuration schauen. Beides habe ich nicht gemacht.

Genauso vorsichtig muss man mit der naheliegenden Erzählung sein, Intel habe hier dieselbe Platine für eine Dual-Chip-Variante vorgesehen. Das passt zur Namenslogik, das S in SCCP steht ja für Single, und es passt zum leeren Platz. Eine offizielle Bestätigung für ein 8950-DCCP oder Ähnliches habe ich trotzdem nicht gefunden. Also: plausible Deutung, kein Faktum.

Am Rand der Platine sitzt außerdem noch ein schwarzer, geschirmter Steckverbinder mit der Bezeichnung J8. Der macht das, was bei einer Grafikkarte der Zusatzstecker macht: Stromversorgung direkt vom Netzteil. Das passt zu den bis zu 40 Watt aus dem Datenblatt, denn ein PCIe-Steckplatz dieser Bauform liefert nach Spezifikation nur 25 Watt. Dazu mehrere Spannungsregler mit Speicherdrosseln, ein 100-MHz-Quarz und Siebdruck-Markierungen für x1, x4 und x8 am Kartenrand.

Einbau: erfreulich langweilig

Die Karte steckt jetzt in einem Slot mit PCIe 4.0 x8 an einem Xeon Gold 5315Y, unter Linux Mint mit Kernel 7.0. Und dann passiert genau das, was man sich von einem In-Kernel-Treiber wünscht: nichts Besonderes.

[   36.915329] dh895xcc 0000:c5:00.0: enabling device (0140 -> 0142)
[   37.744772] dh895xcc 0000:c5:00.0: qat_dev0 started 12 acceleration engines

Kein Paket installiert, keine Firmware nachgeladen, keine Konfigurationsdatei, kein adf_ctl. Die Module qat_dh895xcc und intel_qat laden von allein, die Firmware qat_895xcc.bin.zst lag über linux-firmware längst auf der Platte. Zwölf Acceleration Engines starten, alle Selftests bestehen, der Heartbeat meldet sich gesund. Ein Chip von 2013 auf einer Karte von 2017 in einem Board von 2021 unter einem Kernel von 2026, und es ist ein Nichtereignis. Das ist ein starkes Argument für In-Tree-Treiber, und es wird weiter unten noch bitter kontrastiert.

Was man zum Prüfen braucht:

lspci -nn | grep -i qat
dmesg | grep -i dh895
grep -c qat /proc/crypto
ls /sys/kernel/debug/qat_dh895xcc_0000:c5:00.0/

Das debugfs-Verzeichnis ist die interessanteste Fundstelle der ganzen Karte. Dort liegt in dev_cfg die komplette, vom Treiber selbst generierte Instanzkonfiguration: 16 Krypto-Instanzen, 16 Kompressions-Instanzen, je Instanz 512 gleichzeitige symmetrische oder 128 asymmetrische Requests, dazu Interrupt-Coalescing und ein Heartbeat-Timer. In fw_counters stehen Requests und Responses pro Acceleration Engine, und das ist später mein Beweis, dass die Karte wirklich rechnet und nicht die CPU heimlich einspringt. Dazu heartbeat/status, cnv_errors und 32 Ring-Banks unter transport/.

Ein PCIe-Switch, der Generationen übersetzt

Jetzt löst sich auch auf, warum der PLX-Switch da ist. So sieht der Baum aus:

c2:02.0  Root Port #17          LnkCap Gen4 x8  ->  LnkSta Gen3 x8
  c3:00.0  PLX PEX 8724 Upstream                 ->  LnkSta Gen3 x8
    c4:00.0  Downstream Port #0                  ->  LnkSta Gen2 x8
      c5:00.0  Intel DH895XCC Series QAT   [8086:0435]
    c4:08.0  Downstream Port #8                  ->  LnkSta x0   (leer)

Host-seitig läuft die Karte mit Gen3 x8, also 8 GT/s. Chip-seitig kommen aber nur Gen2 x8 an, also 5 GT/s. Der Coleto Creek ist ein Gen2-Baustein, und damit die Karte am Steckplatz trotzdem als moderne Gen3-x8-Karte auftritt, sitzt der PEX 8724 als Übersetzer dazwischen. Der QAT-Endpunkt behauptet in seiner LnkCap sogar x16, verdrahtet sind aber acht Lanes.

Das ist der erste wirklich belastbare Befund des Tages: das Nadelöhr sitzt auf der Karte, nicht im Steckplatz und nicht in der CPU. Dafür braucht man die Dual-Chip-Spekulation von oben überhaupt nicht.

Zwei weitere Kleinigkeiten aus dem laufenden System, die ich hübsch finde. Die Karte sitzt allein in ihrer IOMMU-Gruppe und unterstützt Function Level Reset, sie lässt sich also ohne ACS-Override-Gebastel per VFIO an eine VM durchreichen. Und sie meldet 32 Virtual Functions, aktiv sind davon null.

Der praktisch wichtigste Nebeneffekt des Einbaus hat aber gar nichts mit Krypto zu tun. Der PLX-Switch belegt vier Busnummern, und dadurch ist meine NVMe von c3:00.0 auf c7:00.0 gewandert. Auf der alten Adresse sitzt jetzt der Upstream-Port des Switches. Ich hatte mir ein setpci-Kommando mit der alten Adresse notiert, und das hätte nach dem Einbau munter in die Register des Switches geschrieben statt in die der SSD. Merke: PCI-Adressen sind nichts, was man sich aufschreibt. Die holt man sich jedes Mal frisch aus lspci. Ist ja jetzt nicht so, als wenn mir das schon mal untergekommen ist bzw. ich so was gefettfingert habe 😛

Die Prioritäten-Tabelle, oder: der Kernel hat schon entschieden

Der Treiber registriert zehn Algorithmen in der Crypto-API des Kernels, alle mit selftest: passed:

xts(aes)                        qat_aes_xts               skcipher   prio=100
ctr(aes)                        qat_aes_ctr               skcipher   prio=100
cbc(aes)                        qat_aes_cbc               skcipher   prio=100
authenc(hmac(sha1),cbc(aes))    qat_aes_cbc_hmac_sha1     aead       prio=100
authenc(hmac(sha256),cbc(aes))  qat_aes_cbc_hmac_sha256   aead       prio=100
authenc(hmac(sha512),cbc(aes))  qat_aes_cbc_hmac_sha512   aead       prio=100
rsa                             qat-rsa                   akcipher   prio=1000
dh                              qat-dh                    kpp        prio=1000
pkcs1(rsa,sha512)               pkcs1(qat-rsa,sha512)     sig        prio=1000
deflate                         qat_deflate               acomp      prio=4001

Diese Prioritäten sind die halbe Geschichte des Beitrags. Die Linux Crypto API wählt bei gleichem Algorithmusnamen immer die Implementierung mit der höchsten Priorität. Wenn man das mit den CPU-Implementierungen auf derselben Maschine vergleicht, wird es sehr deutlich:

AlgorithmusQATbester CPU-Wertwer gewinnt
xts(aes)100xts-aes-vaes-avx2 = 600CPU
cbc(aes)100cbc-aes-aesni höherCPU
authenc(...)100CPU-Kombis höherCPU
rsa1000rsa-generic = 100Karte
dh1000dh-generic = 100Karte
deflate4001deflate-generic = 0Karte

Übersetzt heißt das: für symmetrische Massenverschlüsselung wird diese Karte nie von allein benutzt. Für rsa, dh und deflate dagegen immer. Das ist kein Zufall und kein Konfigurationsfehler, sondern eine Aussage der Kernel-Entwickler darüber, wo Offload bei einer aktuellen CPU überhaupt noch etwas bringt. Man kann diese Tabelle lesen wie ein Urteil über die Karte, und genau so ist sie auch gemeint.

Ein Detail nur mit Vorsicht: pkcs1(qat-rsa,sha512) steht mit Priorität 1000 registriert und hat den Selftest bestanden. Daraus folgt noch nicht, dass Kernel-Modul-Signaturprüfung tatsächlich über die Karte läuft. Die Registrierung macht es möglich, die Priorität spricht dafür, aber um es zu belegen müsste man den Verifikationspfad tracen und dabei die Zähler in fw_counters beobachten. Habe ich nicht gemacht, also behaupte ich es auch nicht.

Bevor die Zahlen kommen: was meine Messung nicht zeigt

Diesen Absatz gibt es, weil die Tabellen danach sonst präziser wirken als sie sind. Wer mir eine Zahl vorhält, soll wissen, wie sie entstanden ist.

  • Alle Kryptomessungen laufen über AF_ALG aus einem Python-Skript mit einem synchronen Request-Loop. Das ist ungefähr der Worst Case für eine asynchrone Offload-Karte: pro Request wird gewartet, statt die Queue tief zu halten, für die die Hardware gebaut wurde. Die Zahlen charakterisieren also diesen Zugangsweg, nicht die Karte an sich.
  • Kein Warmup, keine Wiederholungen, keine Streuung, kein CPU-Pinning. Angaben mit einer Nachkommastelle tragen eine Genauigkeit, die statistisch nicht gedeckt ist. Für belastbare Latenzen bräuchte es Median und P95 über mehrere Läufe.
  • Die Skalierung über Worker nutzt Prozesse, nicht Threads. Kontextwechsel und Speicherkopien gehen also mit in die Zahl ein.
  • cryptsetup benchmark ist selbst nur ein Indikator. Es misst im Speicher über die Crypto-API und ist laut eigener Manpage nicht direkt auf reale Storage-Verschlüsselung übertragbar.
  • Hart sind dagegen die fw_counters. Die kommen aus der Hardware und belegen, dass und wie oft die Karte gerechnet hat. Und die Form der Ergebnisse ist robust: Latenz pro Request hoch, Skalierung über Worker fast linear, CPU bei kleinen Blöcken weit vorn. Nur die absoluten Werte sind weich.

Als Gegner steht bewusst der ungünstigste Fall für die Karte: ein Xeon Gold 5315Y mit acht Kernen und vollem VAES-Befehlssatz. Wenn eine 13 Jahre alte Karte gegen die Rechenwerke einer aktuellen CPU antritt, dann bitte richtig.

Ein Strom, synchron: die Karte verliert deutlich

Zuerst die CPU-Basislinie mit cryptsetup benchmark, damit die Größenordnung steht:

aes-cbc   128b   1168,2 MiB/s enc   3936,0 MiB/s dec
aes-cbc   256b   1004,5 MiB/s enc   3729,8 MiB/s dec
aes-xts   256b   5494,4 MiB/s enc   5510,1 MiB/s dec
aes-xts   512b   5045,2 MiB/s enc   5053,6 MiB/s dec

Und dann der direkte Vergleich über AF_ALG, ein einzelner synchroner Strom:

ImplementierungBlockDurchsatzLatenz pro Operation
xts-aes-vaes-avx24 KiB1157,2 MiB/s3,4 µs
xts-aes-vaes-avx216 KiB2681,2 MiB/s5,8 µs
xts-aes-vaes-avx264 KiB4327,2 MiB/s14,4 µs
qat_aes_xts4 KiB100,3 MiB/s38,9 µs
qat_aes_xts16 KiB244,9 MiB/s63,6 µs
qat_aes_xts64 KiB308,8 MiB/s201,6 µs
cbc-aes-aesni4 KiB583,5 MiB/s6,7 µs
qat_aes_cbc4 KiB79,8 MiB/s48,9 µs

Pro Einzelrequest ist die Karte also grob elfmal langsamer als die CPU, ungefähr 39 gegen 3,4 Mikrosekunden bei 4 KiB. Für ein Bauteil, das mal genau dafür gekauft wurde, ist das eine ernüchternde Zahl.

Nur: das ist nicht die Hardwarelatenz der Karte. Gemessen ist die Latenz dieses Pfades, und der ist lang: Python, sendmsg über AF_ALG, Socket-Puffer, Kernel-Copy, Treiber, PCIe, Karte, und alles wieder zurück, plus Scheduling und Aufwecken des wartenden Prozesses. Ein erheblicher Teil davon kann im Socket-Pfad stecken. Die CPU-Variante läuft durch denselben Overhead, profitiert aber davon, dass sie synchron im selben Kontext rechnet und überhaupt nicht schlafen muss. Belastbar ist deshalb nur die relative Aussage für diesen Zugangsweg und die Form der Kurve. Wer die reine Hardwarelatenz will, braucht ein Frontend ohne Socket-Umweg, also praktisch den Userspace-Stack, und der existiert für diesen Chip nicht mehr. Dazu unten mehr. Das ist eine Messlücke, die ich mit den vorhandenen Mitteln nicht schließen kann, und ich schreibe sie lieber hin als sie zu verstecken.

Viele Ströme: jetzt zeigt sie, wofür sie gebaut wurde

Und dann wird es doch noch spannend. Dieselbe Messung mit parallelen Workern, Blockgröße 16 KiB:

Workerqat_aes_xtsxts-aes-vaes-avx2
1210,7 MiB/s2632,6 MiB/s
2454,1 MiB/s5311,9 MiB/s
4799,3 MiB/s10777,5 MiB/s
81714,5 MiB/s22056,0 MiB/s
162926,7 MiB/s25229,7 MiB/s

Die Karte skaliert von einem auf 16 Worker um fast Faktor 14, also nahezu linear, und bei 16 Workern war sie noch nicht am Ende. Das ist exakt das Verhalten, für das so ein Baustein entworfen wurde: viele gleichzeitige, unabhängige Operationen, keine einzelne schnelle. Eine Karte in einem Loadbalancer sieht nie einen Strom, sie sieht tausende.

Nur skaliert die CPU eben auch. Und sie landet am Ende 8,6 mal höher. Das ist der ganze Punkt dieses Beitrags in einer Zahl. So fertig, feierabend, einpacken!

Dass wirklich die Karte gerechnet hat und nicht heimlich doch die CPU, zeigen die Firmware-Zähler nach dem Lauf:

QAT firmware requests in diesem Lauf: 1.340.416

AE   Requests   Responses
 0:    154181     154181
 1:     94420      94420
 2:     90590      90590
 3:    154177     154177
 ...
11:     90662      90662

Alle zwölf Acceleration Engines haben gearbeitet, Requests und Responses stimmen überall überein, kein verlorener Request. Die Verteilung ist nicht gleichmäßig, sondern fällt in Dreiergruppen von etwa 154k, 94k und 90k. Das kommt von der Zuordnung Ring-Bank zu Engine und ist so ein Detail, an dem man beim Lesen kleben bleibt.

Wo der Deckel liegen könnte, und warum ich mich da nicht festlege

Die 2926,7 MiB/s liegen auffällig nah an der Kapazität des internen Links:

2926,7 MiB/s Nutzdaten = 3069 MB/s
Jedes Byte muss zweimal über den Bus: rein zum Verschlüsseln, raus als Chiffrat.
Gen2 x8 = 5 GT/s x 8 Lanes x 8/10 = 4000 MB/s pro Richtung
3069 / 4000 = 77 % der theoretischen Richtungskapazität

77 Prozent Nutzlast auf einem PCIe-Link sind praktisch der Anschlag, mit dem TLP-Overhead bei 256 Byte MaxPayload liegt das erreichbare Maximum bei etwa 85 bis 90 Prozent. Das sieht also sehr nach einem Bus-Limit aus.

Aber: meine Messung kann das nicht trennen. Sie zeigt, dass dieser Zugangsweg in der Nähe eines Ceilings landet, und sie kann nicht sagen, ob das der PCIe-Link, der Treiber oder AF_ALG ist. Die Rechnung oben ist ein Plausibilitätsargument, keine Messung des Busses. Sauber wäre es, PCIe-Bandwidth-Events über die Uncore-Zähler mitzuschreiben. Das ist der wichtigste offene Messpunkt in diesem Beitrag, weil eine der Hauptaussagen daran hängt.

Noch eine Rechnung, die verlockend glatt aufgeht und bei der man aufpassen muss. Die gemessenen 2926,7 MiB/s sind 24,6 Gbit/s, das Datenblatt sagt 50 Gbit/s, das sind fast genau 49 Prozent. Und verdrahtet sind acht von 16 Lanes, also genau die Hälfte. Der Kurzschluss liegt auf der Zunge, und er ist falsch: „mit x16 wäre man auf dem Datenblattwert“ und „die Platine war für zwei Chips gedacht“ sind zwei verschiedene Hypothesen, nicht eine. In einer Dual-Chip-Bestückung bekäme jeder Chip x8, keiner käme je auf x16, und das Nadelöhr wäre dann der gemeinsame Gen3-x8-Upstream. Rechnerisch landet man auf beiden Wegen bei ungefähr 49 Gbit/s, aber über völlig verschiedene Mechanismen. Wer das zusammenrührt, argumentiert falsch, auch wenn das Ergebnis stimmt. Die Karte selbst hat ja auch nur einen PCIe 8x Anschluss.

Und es gibt eine zweite Lesart, an der der ganze Vergleich mit dem Marketing hängt. Wenn Intels 50 Gbit/s als Summe beider Richtungen gemeint sind, also 25 rein und 25 raus, dann reicht Gen2 x8 dafür aus, und meine gemessenen 24,6 Gbit/s pro Richtung sind aggregiert 49,2 Gbit/s. Dann lautet der richtige Satz nicht „die Karte erreicht die Hälfte“, sondern „die Karte erreicht ihre Spezifikation“. Intel dokumentiert nirgends, wie gezählt wird, also ist das aus den vorliegenden Unterlagen nicht entscheidbar. Die 24,6 Gbit/s stehen fest, nur ihre Deutung hängt an einer undokumentierten Konvention. Natürlich kann ich auch einfach dran vorbei gelesen haben oder ich messe falsch. Korrigiert mich gerne, dann lerne ich selbst etwas \o/

Und jetzt die Enttäuschung: dm-crypt will nicht

Der naheliegendste Anwendungsfall, und ausgerechnet genau der, für den die Karte in meinem FreeBSD-Server gearbeitet hat, funktioniert unter aktuellem Linux nicht:

# cryptsetup plainOpen /dev/ram0 probe --cipher capi:qat_aes_xts-plain64 --key-size 512 --key-file /dev/zero
device-mapper: reload ioctl on probe (252:3) failed: Datei oder Verzeichnis nicht gefunden

Der erste Reflex ist natürlich, dass ich den Namen falsch geschrieben habe. Also Gegenprobe mit derselben capi:-Syntax über sieben Varianten:

Angabe bei --cipherErgebnis
aes-xts-plain64funktioniert
capi:xts(aes)-plain64funktioniert
capi:xts-aes-aesni-plain64funktioniert
capi:xts-aes-vaes-avx2-plain64funktioniert
capi:xts-aes-vaes-avx512-plain64funktioniert
capi:qat_aes_xts-plain64Fehler, ENOENT
capi:qat_aes_cbc-plain64Fehler, ENOENT

dm-crypt kann also sehr wohl einen konkreten Treiber adressieren, sogar xts-aes-vaes-avx512, das mit seiner niedrigen Priorität sonst nie zum Zug käme. Nur die beiden QAT-Treiber fliegen raus. Das ist kein Tippfehler und keine Namensauflösung.

Der Beweis kommt aus dem laufenden Kernel selbst, ausgelesen über die NETLINK_CRYPTO-Schnittstelle. Das ist die belastbare Quelle, denn sie sagt, was dieser Kernel registriert hat, und nicht, was irgendeine Quelldatei im Netz behauptet:

qat_aes_xts              flags=0x00010585   ASYNC | NEED_FALLBACK | TESTED | ALLOCATES_MEMORY
qat_aes_cbc              flags=0x00010485   ASYNC | TESTED | ALLOCATES_MEMORY
qat_aes_ctr              flags=0x00010485   ASYNC | TESTED | ALLOCATES_MEMORY
qat_aes_cbc_hmac_sha256  flags=0x00010483   ASYNC | TESTED | ALLOCATES_MEMORY
qat_deflate              flags=0x0001048a   ASYNC | TESTED | ALLOCATES_MEMORY
qat-rsa                  flags=0x00000406   TESTED
xts-aes-aesni            flags=0x00000405   TESTED
xts-aes-vaes-avx2        flags=0x00000405   TESTED
cbc-aes-aesni            flags=0x00000405   TESTED
deflate-generic          flags=0x0004040a   TESTED

Der Unterschied ist genau ein Bit: 0x00010000, also CRYPTO_ALG_ALLOCATES_MEMORY. Alle symmetrischen QAT-Implementierungen haben es, keine einzige CPU-Implementierung hat es. Die niedrigen Bits sind übrigens kein Flag, sondern der Algorithmustyp.

Und dm-crypt fordert seine Transforms genau mit diesem Bit als Maske an, an drei Stellen im Code:

cc->cipher_tfm.tfms[i]      = crypto_alloc_skcipher(ciphermode, 0, CRYPTO_ALG_ALLOCATES_MEMORY);
cc->cipher_tfm.tfms_aead[0] = crypto_alloc_aead(ciphermode, 0, CRYPTO_ALG_ALLOCATES_MEMORY);
mac                         = crypto_alloc_ahash(mac_alg, 0, CRYPTO_ALG_ALLOCATES_MEMORY);

Die Suche findet damit nichts und liefert ENOENT, und das kommt oben als „Datei oder Verzeichnis nicht gefunden“ an. Kette geschlossen, keine Hypothese mehr. Dass qat-rsa das Flag nicht hat, passt genau ins Bild: RSA läuft über die Karte, weil es kein Block-I/O-Pfad ist.

Die Wendung: das ist eine Leitplanke, kein Bürokratiefehler

An dieser Stelle wollte ich anfangen zu schimpfen. Die Karte kann AES-XTS in Hardware, der Kernel registriert es, und der eine Konsument, für den es gedacht war, weigert sich. Klingt nach Linux, das sich selbst im Weg steht. Ist es aber nicht.

Die Maske stammt von Mikulas Patocka, und die Begründung im Commit ist knapp:

Don’t use crypto drivers that have the flag CRYPTO_ALG_ALLOCATES_MEMORY set. These drivers allocate memory and thus they are unsuitable for block I/O processing.

Der Grund dahinter ist ein Low-Memory-Deadlock. Stell dir vor, es wird auf ein dm-crypt-Gerät geswappt. Der Kernel will Speicher freimachen, schickt die Swap-Out-BIO durch dm-crypt, dm-crypt fragt die Crypto-API, und die fordert daraufhin Speicher an, den es gerade nicht gibt. Ende der Vorstellung.

Und mit QAT ist genau das schon einmal schiefgegangen. Im März 2022 gibt es einen Bericht auf der Kernel-Mailingliste über massive Datenkorruption mit QAT plus dm-crypt plus XFS. Die Diagnose kam von Giovanni Cabiddu, Intel:

The implementations of aead and skcipher in the QAT driver are not properly supporting requests with the CRYPTO_TFM_REQ_MAY_BACKLOG flag set. If the HW queue is full, the driver returns -EBUSY but does not enqueue the request.

Die Folge: dm-crypt wartet endlos auf die Completion eines Requests, der nie an die Hardware gegangen ist. Und das Dateisystem war hinüber. Das ist kein theoretisches Risiko, das ist ein Schadensfall mit Datum.

Die Zeitleiste danach ist lehrreich, weil sie nicht so endet, wie man denkt:

DatumWas passiert
Juli 2020Das Flag CRYPTO_ALG_ALLOCATES_MEMORY wird auf QAT gesetzt
Juli 2020dm-crypt schließt Treiber mit diesem Flag aus (Patocka)
März 2022Der Schadensfall: Datenkorruption mit QAT, dm-crypt und XFS
Mai 2022Intel liefert den eigentlichen Fix, ein Backlog-Mechanismus
Juli 2023Intel will das Flag entfernen, um QAT für dm-crypt zurückzuholen. Nie gemerged.
Juni 2025Stattdessen: Priorität von Skcipher und AEAD wird von 4001 auf 100 gesenkt
August 2026Auf meinem Kernel gemessen: Flag gesetzt, Priorität 100, dm-crypt verweigert

Die Auflösung war also nicht „dm-crypt darf QAT wieder benutzen“, sondern „QAT wird per Priorität aus dem Weg geräumt“. Und die Begründung in diesem Commit ist der stärkste Absatz, den ich zu dieser Karte gelesen habe:

Most kernel applications utilizing the crypto API operate synchronously and on small buffer sizes, therefore do not benefit from QAT acceleration. Reduce the priority of QAT implementations for both skcipher and aead algorithms, allowing more suitable alternatives to be selected by default.

„Synchron und kleine Puffer“ ist exakt das, was ich oben unabhängig gemessen habe, 39 gegen 3,4 Mikrosekunden bei 4 KiB. Der Kernel hat 2025 formal festgestellt, was meine Messung 2026 auf dieser Maschine bestätigt. Und der Patch, der Intels eigene Hardware degradiert, kommt von Intel, mit Acked-by von Eric Biggers. Wenn man es wohlwollend liest, ist das ein Hersteller, der ehrlich ist. Zur Version muss man genau sein: der Commit ging in Mainline 6.17, wurde aber wegen Cc: stable auch in ältere Stable-Zweige zurückportiert, war dort also schon vor dem 6.17-Release wirksam.

Und damit ist die Pointe eine viel bessere als „Linux ist im Weg“: was wie eine willkürliche Blockade aussieht, ist eine Leitplanke aus einem echten Schadensfall. Das erklärt übrigens auch, warum FreeBSD hier lockerer war. Dort gibt es diese Leitplanke nicht, es gibt keine Priority-Hierarchie, die den Beschleuniger aussortiert, und keine Maske, die ihn vom Blockgerät fernhält. Das heißt allerdings nicht, dass das Problem dort nicht existiert. Es heißt nur, dass niemand ein Geländer davor gebaut hat.

Ein Blick auf die FreeBSD-Seite lohnt an dieser Stelle sowieso, denn dort ist der Weg ein struktureller anderer. qat(4) ist ein Treiber für das OpenCrypto-Framework, also für crypto(9). Wer GELI benutzt, muss überhaupt nichts umkonfigurieren: GELI fragt das Framework, und das Framework nimmt den Beschleuniger. Deshalb war die Sache damals auch so unspektakulär, ich habe die Karte gesteckt und die Platten waren schneller.

Ganz so glatt war die Historie allerdings nicht. Im Basissystem gibt es qat(4) erst seit FreeBSD 13.0, und in 14.0 wurde dieser Treiber durch Intels Upstream-Variante ersetzt. Auf dem ersten Server, der noch älter war, kann es diesen Weg also nicht gegeben haben. Die aktuelle Manpage führt die Serie 8925 bis 8955 weiterhin als unterstützte Hardware, dieser Chip ist dort also nicht rausgefallen. Und noch etwas gegen zu viel Nostalgie: im FreeBSD-Forum gibt es einen Thread zur GELI-Performance, in dem QAT zunächst schlechter war als AES-NI. Erst nach dem Umstellen der QAT-Services berichtete der Autor rund 30 bis 34 Prozent Vorsprung bei AES-XTS mit SHA256, und bei anderen Lasten blieben Verluste. Die Karte war also auch damals kein bedingungsloser Gewinn, sondern eine, die zur Konfiguration passen musste.

Der zweite Dämpfer: Intels Userspace hat die Karte fallengelassen

Bleibt der andere große Anwendungsfall: TLS-Terminierung mit nginx oder HAProxy, OpenSSL-Offload. Dafür braucht man qatlib und dazu die QAT-Engine oder den Provider für OpenSSL. Auf meinem System ist davon nichts installierbar, kein Kandidat in den Repos, und OpenSSL 3.0.13 kennt nur den Default-Provider.

Schlimmer als „nicht gepackt“ ist aber der Grund: Intel hat dh895xcc aus qatlib entfernt. Die aktuellen Versionen unterstützen nur noch QAT Gen4, also die 4xxx- und 420xx-Serien. Die frühen Generationen sind nicht mehr dabei. Der Weg wäre der alte Out-of-Tree-Stack, und der baut gegen einen Kernel 7.0 nicht mehr.

Ich formuliere das bewusst nicht als „tot“. Behauptet ist: für diesen Chip ist der heutige Mainstream-Linux-Userspace praktisch abgehängt. Nicht behauptet ist, dass es global unmöglich wäre. Mit altem Kernel, altem Out-of-Tree-Treiber und passender Distribution ließe sich der Stack sicher noch aufbauen, und ältere DPDK-Versionen führten dh895xcc als unterstütztes Gerät. Es ist eine Frage von Aufwand und Kernel-Alter, nicht von Unmöglichkeit.

Die Ironie daran ist schön bitter. Intels eigener Userspace hat die Karte längst aufgegeben, während der Linux-Kernel sie weiter pflegt und beim Booten ohne ein einziges Paket zum Laufen bringt. Wer wissen will, warum In-Tree-Treiber so wertvoll sind: das hier ist der Beweis in einer Karte.

Ein dritter Befund noch, damit ihn niemand für einen Kartenfehler hält: ein einzelnes sendmsg über AF_ALG mit 256 KiB oder mehr blockiert dauerhaft. Aufgefallen ist mir das erst mit der Karte bei 1 MiB, reproduzieren lässt es sich aber mit dem CPU-Cipher genauso. Es ist also nicht die Hardware. Wo genau im AF_ALG-Pfad es hängt, habe ich nicht nachgewiesen, der Socket-Sendepuffer wäre die naheliegende Vermutung, aber eben eine ungeprüfte. Alle Messungen oben sind deshalb auf maximal 64 KiB begrenzt.

Kompression: der einzige Pfad, der von allein läuft, und er schadet

Erinnerst du dich an qat_deflate mit Priorität 4001 gegen deflate-generic mit 0? Jeder Kernel-Konsument, der nach deflate fragt, bekommt die Karte. Realistisch ist das zswap, die komprimierte Auslagerung im RAM.

Also nachgestellt: eine cgroup mit hartem Speicherlimit, 700 MiB gut komprimierbare anonyme Seiten, zswap auf deflate. Und tatsächlich, die Karte komprimiert die Auslagerung, 130.108 Firmware-Requests belegen das. Nur ist der direkte Vergleich bei gleicher Last ziemlich brutal:

zswap-CompressorwallusersysQAT-Requests
lzo (CPU)0,85 s0,11 s0,72 s0
deflate (QAT)9,47 s0,26 s5,13 s130.027

Elfmal langsamer. Und die System-CPU-Zeit steigt sogar von 0,72 auf 5,13 Sekunden, das Offload spart also nicht einmal CPU, es kostet zusätzlich welche. Der Grund ist derselbe wie überall in diesem Beitrag: zswap komprimiert eine 4-KiB-Seite pro Request, und das ist genau der Latenz-Worstcase.

Fair bleiben muss ich hier trotzdem: der Vergleich mischt zwei Effekte, denn Deflate ist algorithmisch auch schlicht teurer als LZO. Sauber wäre deflate auf CPU gegen deflate auf der Karte, und das habe ich nicht gemessen, weil sich deflate-generic bei Priorität 0 nicht ohne Weiteres erzwingen lässt. Die Aussage „QAT-zswap ist keine gute Idee“ trägt der Test, die Aufteilung zwischen Algorithmus und Latenz nicht.

Bleibt eine Frage, die ich hübsch finde: warum steht deflate überhaupt noch auf 4001? In der Datenkorruptions-Diskussion von 2022 schlug Intel vor, die Priorität der betroffenen Algorithmen auf 1 zu senken. Heute stehen Skcipher und AEAD bei 100, die Kompression aber weiter bei 4001. Die naheliegende Deutung: 4001 war ursprünglich die Politik „nimm immer den Beschleuniger“, sie wurde für Krypto nach dem Vorfall zurückgenommen und für Kompression nie. Damit wäre qat_deflate der letzte Überrest dieser alten Haltung, und ausgerechnet der Pfad, der heute noch stillschweigend gewinnt und dabei elfmal langsamer ist. Das ist allerdings meine Deutung, kein Beleg. Die Commit-Historie der Prioritätswerte für die Kompression habe ich nicht nachverfolgt.

Wer das nachbauen will: den Zustand danach wieder zurücksetzen, also enabled=N und compressor=lzo. Ein Dauerbetrieb mit dieser Einstellung wäre eine echte Verschlechterung der Maschine.

Die unvermeidliche Frage: kann man damit Bitcoin oder Monero minen?

Nein. Und zwar aus zwei völlig verschiedenen Gründen, und dieser Unterschied ist der eigentlich interessante Teil.

Monero geht prinzipiell nicht, nicht bloß langsam. Monero nutzt seit 2019 RandomX, und der Algorithmus wurde absichtlich so entworfen, dass Spezialhardware keinen Vorteil hat. Er generiert zur Laufzeit zufällige Programme aus Integer-, Fließkomma- und Branch-Instruktionen und führt sie in einer VM aus, teils JIT-compiliert. Er braucht 2 MiB Scratchpad pro Thread, permanent random-access beschrieben und gelesen. Und im Fast-Mode zusätzlich einen Datensatz von rund 2,08 GiB im RAM, aus dem die VM ständig liest. Das macht RandomX speichergebunden statt rechengebunden, und deshalb sind ASICs dort wirtschaftlich uninteressant.

Die QAT-Karte ist das exakte Gegenteil davon: eine Festfunktions-Pipeline. Sie kann AES, SHA, RSA, DH und Deflate, und nichts sonst. Sie hat keinen Befehlssatz, kein Scratchpad-Konzept und keinen Zugriff auf einen 2-GiB-Arbeitsdatensatz. AES kommt in RandomX zwar vor, aber als eingebetteter Schritt in der VM-Schleife, nicht als abtrennbare Massenoperation, die man an einen Coprozessor auslagern könnte. Die CPU dieser Maschine kann RandomX übrigens sehr wohl. Der Algorithmus ist ja genau für CPUs gemacht.

Bitcoin geht theoretisch, praktisch ist es absurd. Bitcoin ist doppeltes SHA-256 über einen 80 Byte großen Blockheader, und SHA-256 kann die Karte. Nur scheitert es an zwei harten Punkten.

Erstens ist es über diesen Stack nicht einmal ansprechbar. Der In-Kernel-Treiber registriert kein reines SHA-256, sondern nur authenc(hmac(sha256),cbc(aes)), also HMAC-authentifizierte Verschlüsselung. Nackte Hashes bekäme man nur über den Userspace-Stack, und der existiert für diesen Chip nicht mehr. Es gibt also gar keinen Weg, der Karte einen Blockheader zum Hashen zu geben.

Zweitens ist die Größenordnung hoffnungslos, und dafür genügt eine geschenkte Obergrenze. Selbst wenn die Karte ihre volle Datenblattleistung als reines Hashing liefern könnte, landet man bei realistischen 64 bis 128 Byte pro Hash-Operation in der Größenordnung von 10⁷ bis 10⁸ Hashes pro Sekunde, also höchstens einige zehn MH/s. Erreichen wird sie das nie, die gemessenen Latenzen zeigen ja, dass sie bei kleinen Nutzlasten zwei Größenordnungen unter ihrem Sweet Spot arbeitet.

HashrateCharakter der Zahl
QAT 8950höchstens 10⁸ H/sunerreichbare Obergrenze
ein aktueller ASIC-Mineretwa 2 mal 10¹⁴ H/sProduktangabe
Bitcoin-Gesamtnetz, 03.08.20269,3 mal 10²⁰ H/sgemessen, 932 EH/s

Selbst mit der geschenkten Obergrenze liegt ein einzelner ASIC noch um mindestens sechs Größenordnungen über der Karte, und das Gesamtnetz um dreizehn. Der Anteil am Netz wäre günstigstenfalls in der Größenordnung 10⁻¹³, bei zehn Minuten Blockzeit also eine erwartete Wartezeit jenseits jeder sinnvollen Zeitskala, bei 40 Watt Dauerlast. Ich verzichte hier absichtlich auf eine konkrete Jahreszahl. Die klingt zwar gut, wäre aber Scheinpräzision auf einer geschätzten Grundlage. Größenordnungen sind hier die ehrlichere Währung, und sie sind genauso eindrucksvoll.

Die eigentliche Pointe ist aber eine sprachliche. Krypto-Beschleuniger heißt Kryptographie, nicht Kryptowährung. Die Karte ist für das gebaut, was Verbindungen und Platten schützt: TLS-Handshakes, IPsec-Tunnel, AES-XTS auf Blockgeräten. Dass beide Bedeutungen dasselbe Wort benutzen, ist ein Sprachunfall, und ich bin ziemlich sicher, dass er der Kryptographie mehr geschadet hat als der Kryptowährung.

Betrieb: man fliegt thermisch blind

Eine praktische Warnung, falls jemand auf die Idee kommt, so ein Ding in einen Desktop zu stecken: die Karte hat keinen Temperatursensor. sensors zeigt nichts, der BMC kennt sie nicht, in der SDR steht kein Eintrag. Man sieht also nicht, wie warm sie wird.

Und die Randbedingungen sind ungünstig: der Kühlkörper ist rein passiv und für den Querstrom eines Rackgehäuses ausgelegt, spezifiziert sind 0 bis 55 Grad Umgebungstemperatur, es dürfen bis zu 40 Watt Verlustleistung sein, und mein Gehäuse ist auf Ruhe optimiert, mit bewusst langsam drehenden Lüftern. Die Netzwerkkarte im Nachbarslot liegt schon im Leerlauf bei 61 Grad, und die beiden teilen sich denselben schlechten Luftstrom. Wie warm die Karte unter der Last aus diesem Beitrag wirklich geworden ist, weiß ich nicht. Ohne Sensor bräuchte es ein IR-Thermometer oder ein Thermoelement. Steht auf der Liste.

Überflüssig gewordene Technik, und warum mir das trotzdem naheliegt

Diese Karte ist für mich so etwas wie eine Soundkarte. Es gab eine Zeit, da war eine dedizierte Soundkarte selbstverständlich, weil der Rest der Maschine das einfach nicht konnte. Ich hänge immer noch an den alten Creative-Karten, nicht nur an den ISA-Dingern, auch an den späteren. Die hatten eine Wertigkeit, ein Gewicht, eine Bestückung, die man ansehen konnte. Man hat ein Bauteil gekauft, das eine Aufgabe hatte, und das hat man auch gesehen.

Heute steckt in meiner Workstation nicht einmal mehr eine Onboard-Lösung im Einsatz, sondern ein Behringer 302USB, also ein kleines Mischpult mit USB-Audiointerface. Für meinen Fall reicht das absolut. Die Aufgabe ist nicht verschwunden, sie hat nur ihren Platz gewechselt, und die CPU rechnet das nebenbei mit, ohne dass es jemandem auffällt. Genau das ist mit Krypto passiert. AES-NI und VAES haben die Beschleunigerkarte nicht besiegt, sie haben sie aufgesogen.

Immer kleiner, komplexer und leistungsfähiger ist total geil. Ohne diese Entwicklung gäbe es die Hälfte von dem nicht, was wir heute technisch machen. Aber sie macht das Verstehen sehr viel schwieriger. Mein alter C64 und der VC20 davor, die Dinger hat man noch verstanden. Eine CPU mit rund einem Megahertz in einem 40-Pin-Gehäuse, groß genug und langsam genug, dass man mit dem Oszilloskop an einzelne Pins konnte und dabei etwas gesehen hat. Man konnte am Speicher nachmessen. Das war begreifbar im wörtlichen Sinn. An dem Xeon in dieser Maschine messe ich nichts nach. Der Die ist unter einem Heatspreader, die Signale sind differentiell und liegen im Gigahertzbereich, und selbst wenn ich rankäme, wäre mein Oszilloskop zu langsam. Dass bei dieser Karte das nackte Silizium offen liegt, ist ein Zufall der Bauform, und trotzdem freut es mich jedes Mal.

Dafür haben wir heute AI, um uns Dinge erklären zu lassen, und das ist ein echter Gewinn. Ich komme damit an Ecken, an die ich vor zehn Jahren nur mit sehr viel mehr Zeit gekommen wäre. Wir müssen nur alle aufpassen, dass wir dabei nicht aufhören zu verstehen. Der Unterschied zwischen „ich habe es erklärt bekommen“ und „ich habe es verstanden“ ist genau der Unterschied zwischen diesem Beitrag und einer Feature-Liste. Und ein Teil davon passiert ja bereits in der AI-Entwicklung selbst: wir verstehen nicht mehr im Detail, was in diesen Systemen passiert. Stand jetzt halte ich das für ein Problem. Vielleicht ist es aber auch bald einfach das Normale, und ich bin der Typ, der dem Oszilloskop nachtrauert.

Ehrliche Gesamteinschätzung

Als Produktivkomponente ist die Karte in dieser Maschine sinnlos. Eine einzige moderne CPU der Einstiegsklasse schlägt sie bei symmetrischer Krypto um Faktor 8,6, der Anwendungsfall, für den sie gekauft wurde, ist unter Linux versperrt, Intels Userspace hat sie aufgegeben, sie zieht laut Datenblatt bis zu 40 Watt für etwas, das die CPU besser kann, und gekühlt wird sie für einen Luftstrom, den mein Gehäuse nicht liefert.

Als Lehr- und Erzählobjekt ist sie ausgezeichnet. An diesem einen Stück Platine lassen sich Krypto-Offload als Architektur, Latenz gegen Durchsatz, warum Queue-Tiefe alles ist, PCIe-Generationen und Lane-Budgets, PCIe-Switches, SR-IOV, IOMMU-Gruppen und die Prioritätslogik der Linux Crypto API erklären. Ich kenne wenige Bauteile, die auf so kleiner Fläche so viele Anknüpfungspunkte haben. Und sie funktioniert einfach, nach 13 Jahren, ohne ein einziges installiertes Paket.

Genau diese Spannung war der Grund, sie überhaupt noch einmal einzustecken.

Was offen bleibt

  • Die Karte per VFIO an eine FreeBSD-VM durchreichen und dort GELI auf QAT laufen lassen, also die Original-Nutzung rekonstruieren. Sie ist allein in ihrer IOMMU-Gruppe, technisch steht dem nichts im Weg. Das ist der Versuch, auf den ich am meisten Lust habe.
  • SR-IOV aktivieren und sehen, was der Treiber mit 32 Virtual Functions macht. Völlig ungetestet.
  • PCIe-Zähler messen, um zu belegen oder zu widerlegen, dass wirklich der Bus limitiert und nicht der Zugangspfad. Der wichtigste offene Messpunkt.
  • Die Benchmarks methodisch nachziehen: Warmup, mehrere Läufe, Median und P95, CPU-Pinning.
  • Kernel-RSA über die Karte messen. qat-rsa gewinnt per Priorität, aber es gibt keinen AF_ALG-Zugang zu akcipher.
  • Temperatur und echte Leistungsaufnahme, beides nur extern messbar.
  • Das PLX-EEPROM auslesen, um die Lane-Aufteilung zu belegen statt zu vermuten.

Siehe auch

Hast du so eine Karte noch im Einsatz, vielleicht sogar noch produktiv? Dann würde mich das wirklich interessieren, und ihr dürft mich sehr gerne fragen.

Einen modernen OpenPGP-Schlüssel bauen: Ed25519, drei Unterschlüssel und die Härtung, die ihn geschwächt hat

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.

OpenPGP-Schlüssel mit Ed25519-Hauptschlüssel, drei Unterschlüsseln und Warnung vor fehlenden Algorithmus-Präferenzen durch eine fehlerhafte 3DES-Härtung.

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

BestandteilWert
Hauptschlüsseled25519 0x893DE0CDDE986DEB, Verwendung [C], also nur zertifizieren
Fingerabdruck45FC D081 ADB5 4872 EA5B 06B9 893D E0CD DE98 6DEB
Signatur-Unterschlüsseled25519 0xD788641D8588A674
Verschlüsselungs-Unterschlüsselcv25519 0x429D03637892821A
Authentisierungs-Unterschlüsseled25519 0x22F2E3234664DDBE
UIDsSebastian van de Meer, dazu eine Foto-UID mit 10851 Byte
Gültigkeiterzeugt am 24.07.2026, läuft am 23.07.2031 ab
Vorgängered25519 0x5F279C362EEAB216, gültig bis 31.12.2026, zeichnet den neuen gegen
FremdzertifizierungGovernikus/eID 0x5E5CCCB4A4BF43D7, Level 3
UmgebungGnuPG 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:

primary  key flags: 01   certify
[S]      key flags: 02   sign
[E]      key flags: 0C   encrypt communications + encrypt storage
[A]      key flags: 20   authenticate

Teil A: den Schlüssel bauen

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.

Voraussetzungen

gpg --version
# gpg (GnuPG) 2.4.4
# libgcrypt 1.10.3

GnuPG 2.4.x ist die Annahme für den ganzen Beitrag. Zwei Dinge gleich vorweg, weil sie sonst später wehtun:

  • --quick-generate-key verweigert 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:

enable-ssh-support
default-cache-ttl 180
max-cache-ttl 600
pinentry-program /usr/bin/pinentry-gnome3

Und dirmngr.conf:

honor-http-proxy
disable-ldap

Den Hauptschlüssel erzeugen

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.

gpg --export kernel-error@kernel-error.com | gpg --list-packets | grep -E 'pref-|features'

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.

FPR=45FCD081ADB54872EA5B06B9893DE0CDDE986DEB
gpg --batch --quick-add-key $FPR ed25519  sign 5y
gpg --batch --quick-add-key $FPR cv25519  encr 5y
gpg --batch --quick-add-key $FPR ed25519  auth 5y

Die Foto-UID

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.

gpg --export-secret-keys --armor $FPR > secret-key-backup.asc
gpg --output revoke.asc --gen-revoke $FPR

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.

RESTORE=$(mktemp -d); chmod 700 "$RESTORE"
gpg --homedir "$RESTORE" --import secret-key-backup.asc
gpg --homedir "$RESTORE" --with-subkey-fingerprint --list-secret-keys $FPR
rm -rf "$RESTORE"

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

Kontrolliert wird das an einem einzigen Zeichen:

gpg -K
# sec#  ed25519/0x893DE0CDDE986DEB
# ssb   ed25519/0xD788641D8588A674
# ssb   cv25519/0x429D03637892821A
# ssb   ed25519/0x22F2E3234664DDBE

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.

gpg --default-key 0x5F279C362EEAB216 --sign-key $FPR

Die externe Zertifizierung über Governikus

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:

ArtefaktBefehlGrößeInhalt
minimal, ASCIIgpg --armor --export $FPR16772 B1 UID plus Foto, 3 Unterschlüssel, 5 Signaturen
vollständig, ASCIIgpg --armor --export-options no-export-minimal --export $FPR17833 Bzusätzlich 3 Fremdsignaturen, also 8 Signaturen
WKD, binärgpg --no-armor --export-options no-export-minimal --export $FPR13108 Bwie vollständig, nur binär
DANE, binär minimalsiehe unten1298 B1 UID, kein Foto, 4 Signaturen

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:

gpg --export-options export-clean      --export $FPR | gpg --list-packets | grep -c '^:signature packet:'   # 5, immer noch minimal
gpg --export-options no-export-minimal --export $FPR | gpg --list-packets | grep -c '^:signature packet:'   # 8, korrekt

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:

gpg --no-armor --export-options export-minimal --export-filter keep-uid='mbox = kernel-error@kernel-error.com' --export $FPR > openpgpkey.bin

Zwei Anmerkungen noch zu den Formaten, weil sich in der 2.4er Reihe etwas geändert hat:

  • --print-dane-records gibt 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:

KanalFormatWarum dieser Kanal
keys.openpgp.orgvoll hochgeladen, Server wirft Signaturen und Foto wegder einzige Keyserver, der die Mailadresse validiert, und der, den moderne Clients abfragen
keyserver.ubuntu.comvollklassischer SKS-Nachfolger, behält Fremdsignaturen
pgpkeys.euvollzweiter Klassiker, Redundanz
DANE / OPENPGPKEYminimal binär, 1298 BVertrauen hängt an DNSSEC statt an einem Signaturgraphen, muss klein bleiben, es ist ein DNS-Record
WKD advancedkein eigener Export, CNAME auf wkd.keys.openpgp.orgspiegelt keys.openpgp.org automatisch, lässt sich hier nicht selbst hosten
WKD direct (Apex)voll binär, 13108 Bselbst gehostet, deshalb die einzige Stelle mit Foto und Fremdsignaturen
security.txt und Direktdownloadvoll ASCII, 17833 Bauffindbar 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:

hashed subpkt 11 (pref-sym-algos:  9 8 7)      AES256, AES192, AES128
hashed subpkt 21 (pref-hash-algos: 10 9 8)     SHA512, SHA384, SHA256
hashed subpkt 22 (pref-zip-algos:  2 3 1 0)    ZLIB, BZIP2, ZIP, unkomprimiert
hashed subpkt 34 (pref-aead-algos: 2)          OCB, fehlt auf diesem Schlüssel
hashed subpkt 30 (features: 05)                Feature-Bits, siehe unten
hashed subpkt 23 (keyserver preferences: 80)   "no-modify"

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 keine pref-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:

pref-sym-algos: 9 8 7 2 · pref-aead-algos: 2 · pref-hash-algos: 10 9 8 11 2 · features: 07

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 TestErgebnis
Juli-gpg.conf, unverändertfeatures: 04, gar keine pref-*
ohne disable-cipher-algo 3DESpref-sym-algos: 9 8 7 2, features: 07
ohne alle disable-cipher-algo-Zeilenpref-sym-algos: 9 8 7 2, features: 07
ohne cipher-algo AES256weiterhin kaputt, features: 04
nur disable-cipher-algo 3DES vorhandenkaputt, features: 04
disable-cipher-algo 3DES plus explizite Preferences:-Zeilesauber, 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:

SP=$(mktemp -d)
mk() {  # $1 = Name, $2 = bad|good
  export GNUPGHOME="$SP/$1"; mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME"
  [ "$2" = bad ] && echo "disable-cipher-algo 3DES" > "$GNUPGHOME/gpg.conf"
  cat > "$SP/p.txt" <<EOF
Key-Type: eddsa
Key-Curve: Ed25519
Key-Usage: sign
Subkey-Type: ecdh
Subkey-Curve: cv25519
Subkey-Usage: encrypt
Name-Real: Demo $1
Name-Email: $1@example.invalid
Expire-Date: 1y
%no-protection
%commit
EOF
  gpg --batch --gen-key "$SP/p.txt" 2>/dev/null
  gpg --export -a "$1@example.invalid" > "$SP/$1.asc"
}
mk nopref bad
mk withpref good

export GNUPGHOME="$SP/sender"; mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME"
echo "auto-key-locate local" > "$GNUPGHOME/gpg.conf"
gpg -q --import "$SP"/*.asc
echo hi > "$SP/m.txt"
for r in nopref withpref; do
  gpg --trust-model always --yes -e -r "$r@example.invalid" -o "$SP/$r.gpg" "$SP/m.txt"
  printf '%-9s ' "$r:"
  GNUPGHOME="$SP/$r" gpg -q -d "$SP/$r.gpg" 2>&1 | grep -i 'encrypted data'
done

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.

Siehe auch

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.

Keyoxide: den OpenPGP-Schlüssel an Online-Identitäten binden, vier grüne Haken und ein rotes Kreuz

Moin. Seit dem 24. Juli 2026 habe ich einen neuen GPG-Schlüssel. Ed25519, gültig bis 2031, der Hauptschlüssel zertifiziert nur noch und hat für Signieren, Verschlüsseln und Authentisieren je einen eigenen Unterschlüssel. Technisch ein hübsches Ding. Der alte Schlüssel bleibt bis Ende des Jahres gültig und hat den neuen gegengezeichnet, damit der Wechsel keine harte Kante bekommt.

OpenPGP-Schlüssel mit Keyoxide-Identity-Claims für kernel-error.de, kernel-error.com, GitHub und Matrix, vier verifizierte Claims und ein entfernter Claim

Und dann stand da die Frage, die eigentlich immer die wichtigere ist und trotzdem meistens hintenrunterfällt: Woher soll irgendwer wissen, dass dieser Schlüssel mir gehört? Nicht „wo finde ich den Schlüssel“, das ist gelöst. Sondern: Warum sollte jemand glauben, dass die Person, die diesen Schlüssel kontrolliert, dieselbe ist wie die hinter github.com/Kernel-Error, hinter dem Mastodon-Account und hinter dieser Domain?

Die alten Antworten funktionieren nicht mehr

Die klassische Antwort auf diese Frage heißt Web of Trust. Ich unterschreibe deinen Schlüssel, du unterschreibst meinen, irgendwann spannt sich ein Netz aus Signaturen über die Welt und jeder kann über ein paar Ecken einen Pfad zu jedem finden. Elegante Idee. Nur: Wann warst du zuletzt auf einer Keysigning-Party? Eben. Ich auch nicht.

Dazu kommt ein sehr praktisches Problem, über das kaum jemand redet: keys.openpgp.org, der Keyserver, den heute praktisch alle benutzen, veröffentlicht Fremdsignaturen grundsätzlich nicht. Zuverlässig überleben dort nur Selbstsignaturen, einzige Ausnahme sind eigens attestierte Fremdzertifizierungen, und die benutzt in der Praxis kaum jemand. Das ist als Schutz gegen Signatur-Flooding absolut nachvollziehbar, bedeutet aber eben auch: Das Web of Trust reist nicht mehr mit. Du kannst deinen Schlüssel von hundert Leuten unterschreiben lassen, auf dem meistgenutzten Keyserver sieht ihn trotzdem niemand mit diesen Unterschriften.

Die zweite klassische Antwort waren Zertifizierungsstellen für Personen. CAcert habe ich hier vor Jahren mal vorgestellt, und die Volksverschlüsselung wurde 2025 eingestellt. Übrig geblieben ist bei mir eine Zertifizierung über Governikus, die die Beglaubigung im Auftrag des BSI macht, also eine eID-basierte Bestätigung mit Level 3, die tatsächlich meinen bürgerlichen Namen an den Schlüssel bindet. Das ist eine der letzten echten Klarnamen-Bindungen, die man noch bekommt. Und ausgerechnet die ist eine Fremdsignatur, fliegt auf keys.openpgp.org also raus.

Was schon vorher da war, und was es eben nicht beweist

Beim Thema Erreichbarkeit war ich schon vorher gut aufgestellt. Der Schlüssel liegt im Web Key Directory, sowohl in der advanced als auch in der direct method. Er steht als OPENPGPKEY-Record in einer DNSSEC-signierten Zone, wie ich das hier vor Jahren schon einmal beschrieben habe. Er liegt auf keys.openpgp.org, keyserver.ubuntu.com und pgpkeys.eu. Er ist in der clearsigned security.txt referenziert, die zu meinem Umgang mit Vulnerability Reports gehört. Und er liegt schlicht als Datei zum Direktdownload.

Sieben Kanäle also. Alle beweisen exakt eine Sache: dass man den Schlüssel findet. Die Governikus-Zertifizierung beweist zusätzlich einen Namen. Aber keiner dieser Kanäle beweist, dass derselbe Schlüssel auch das GitHub-Konto kontrolliert, oder den Matrix-Account, oder die Domain als Identität und nicht nur als Hostingort einer Datei. Genau diese Lücke füllen Identity Claims, und genau dafür gibt es Keyoxide.

Der wichtigste Satz zuerst: Keyoxide hat keine Accounts

Das ist das größte Missverständnis, und ich schreibe es deshalb so deutlich wie möglich hin: Es gibt bei Keyoxide keine Registrierung, keinen Login, kein Profil zum Ausfüllen, keine API-Keys und keine Daten auf deren Seite. Man legt dort nichts an. Man kann dort gar nichts anlegen.

keyoxide.org ist ein zustandsloser Betrachter. Wenn jemand ein Profil öffnet, passiert Folgendes:

  • Der öffentliche Schlüssel wird geholt, per WKD oder von einem Keyserver.
  • Aus der Selbstsignatur werden die Notationen mit dem Namen proof@ariadne.id ausgelesen.
  • Jeder Claim wird live gegen den genannten Endpunkt aufgelöst, je nach Claim-Typ direkt im Browser oder über einen Proxy. Ein TXT-Record lässt sich aus einer Webseite heraus nun mal nicht selbst abfragen.
  • Grüne Haken werden gerendert, und danach vergisst der Dienst alles wieder.

Die Konsequenz daraus ist der eigentliche Clou: Der Schlüssel ist das Profil. Wenn keyoxide.org morgen offline geht, ist nichts kaputt. Die Claims stehen weiter im Schlüssel, die Proofs liegen weiter an ihren Endpunkten, und jeder kann die komplette Prüfung mit dig und curl von Hand nachvollziehen. Weiter unten zeige ich genau das.

Das ist das genaue Gegenmodell zu Keybase, wo die Verknüpfungen auf einem Server einer Firma lagen. Als die Firma verkauft wurde, war der Ärger groß und die Frage berechtigt, was mit den Identitäten passiert. Bei diesem Modell stellt sich die Frage nicht, weil der Betreiber nichts hält, was verloren gehen könnte. Die zugrundeliegende Spezifikation heißt Ariadne Identity, Keyoxide ist nur eine Implementierung davon.

Zwei Hälften, die aufeinander zeigen

Jeder Proof besteht aus zwei Aussagen, die sich gegenseitig referenzieren:

HälfteLiegt inSagt
Claimder Selbstsignatur des Schlüssels„Ich kontrolliere kernel-error.de“
Proofgenau diesem Endpunkt„Der Schlüssel mit dem Fingerprint 45FC… gehört mir“

Auf den ersten Blick sieht das zirkulär aus. Der Schlüssel sagt, ihm gehöre die Domain, und die Domain sagt, ihr gehöre der Schlüssel. Beweist das nicht einfach gar nichts? Doch, und zwar genau deshalb, weil es zirkulär ist: Nur wer beides kontrolliert, kann beide Hälften zur Deckung bringen.

Spielen wir es durch. Jemand klaut meinen geheimen Schlüssel. Er kann jetzt beliebige Claims hineinschreiben, zum Beispiel „ich kontrolliere example.org“. Aber er kann bei example.org keinen TXT-Record setzen, der auf meinen Fingerprint zeigt. Der Claim bleibt rot. Umgekehrt: Jemand übernimmt eine meiner Domains und setzt dort einen TXT-Record mit meinem Fingerprint. Schön, aber ohne den geheimen Schlüssel kann er den passenden Claim nicht in die Selbstsignatur schreiben. Auch das ergibt keinen grünen Haken. Ein Angreifer mit einer der beiden Hälften bekommt nichts. Er braucht beide.

Warum eine Notation und keine Signatur

Ein Claim ist technisch ein Notation-Subpacket in der Selbstsignatur einer User-ID. Eine Notation ist ein frei definierbares Schlüssel-Wert-Paar, das im Signaturpaket mitsigniert wird. Der Name ist bei Ariadne immer proof@ariadne.id, der Wert beschreibt den Endpunkt.

Und hier schließt sich der Kreis zu dem Keyserver-Problem von oben. Weil die Claims in der Selbstsignatur stehen und nicht in einer Fremdsignatur, kann kein Keyserver sie wegputzen. Meine Governikus-Zertifizierung ist auf keys.openpgp.org verschwunden, weil sie eine Fremdsignatur ist. Die Keyoxide-Claims sind da, weil ein Keyserver eine Selbstsignatur nicht entfernen kann, ohne den Schlüssel dabei zu zerstören. Das ist für mich das stärkste praktische Argument, Notationen zu sammeln statt Unterschriften.

Vier Claims, vier sehr unterschiedliche Qualitäten

Live sind am Ende vier Claims, alle ausschließlich auf der Namens-UID:

proof@ariadne.id=dns:kernel-error.de?type=TXT
proof@ariadne.id=dns:kernel-error.com?type=TXT
proof@ariadne.id=https://gist.github.com/Kernel-Error/b51b81489a0600908cf09aca13765b9c
proof@ariadne.id=matrix:u/kernel-error:kernel-error.com?org.keyoxide.r=dBfQZxCoGVmSTujfiv:matrix.org&org.keyoxide.e=q3zhGJTbPWZD5LjUnsnAI9p43YzHXglniNv_SL7dldQ
Keyoxide-Profil von Sebastian van de Meer mit vier verifizierten Identity Claims: kernel-error.com und kernel-error.de per DNS, dazu GitHub und Matrix, alle mit grünem Haken.
Vier Claims, vier grüne Haken. Geprüft wird das erst beim Aufruf, gespeichert ist bei Keyoxide davon nichts. Der Fingerprint darunter ist der einzige Anker.

Das öffentliche Profil dazu liegt unter keyoxide.org/45FCD081ADB54872EA5B06B9893DE0CDDE986DEB. Bevor jetzt jemand vier grüne Haken sieht und denkt, das seien vier gleichwertige Beweise: sind sie nicht, und das gehört ausgesprochen.

  • Die beiden DNS-Claims sind mit Abstand die stärksten. Sie liegen in meinen eigenen, DNSSEC-signierten Zonen auf meinen eigenen Nameservern. Wer den Record fälschen will, muss meine Domain- oder DNS-Verwaltung übernehmen. Unterwegs fälschen reicht nicht, sofern der Prüfer DNSSEC auch wirklich validiert.
  • Der Gist-Claim hängt daran, dass GitHub sein URL-Schema beibehält und den Gist nicht wegräumt.
  • Der Matrix-Claim hängt an matrix.org und daran, dass ein bestimmter Raum weiter existiert und die Nachricht nicht gelöscht wird.

Ein Claim ist nie stärker als der Endpunkt, auf den er zeigt. Vier grüne Haken nebeneinander suggerieren eine Gleichwertigkeit, die es nicht gibt.

Domain-Proof: ein TXT-Record am Apex

Der Proof für eine Domain ist ein TXT-Record. Bei mir in beiden Zonen:

kernel-error.de.   IN TXT  "openpgp4fpr:45FCD081ADB54872EA5B06B9893DE0CDDE986DEB"
kernel-error.com.  IN TXT  "openpgp4fpr:45FCD081ADB54872EA5B06B9893DE0CDDE986DEB"

Am Apex, nicht unter www, und das ist eine bewusste Entscheidung. Der Claim behauptet etwas über die Identität der Domain, nicht über einen Webserver-Hostnamen. www ist nur ein Host darunter. Keyoxide rendert den Claim außerdem als den Domainnamen, und kernel-error.de liest sich als Identität, während www.kernel-error.de sich als Webseite liest. Kleiner Unterschied, aber genau der Punkt, um den es hier geht.

Beide TLDs bekommen den Record, weil sie bei mir verschiedene Rollen haben. Die .com ist die Mail-Identität mit WKD und DANE, die .de ist die Webpräsenz mit der security.txt. Beides bin ich, also gehört beides an den Schlüssel gebunden.

Beide Zonen sind DNSSEC-signiert und laufen mit inline-signing. Für so eine Bearbeitung heißt das: einfrieren, editieren, prüfen, wieder auftauen. Ein rndc reload ist hier der falsche Befehl.

rndc freeze kernel-error.de
# Zonendatei editieren, Serial hochzählen
named-checkzone kernel-error.de /path/to/kernel-error.de.zone
rndc thaw kernel-error.de

Angenehm unspektakulär war dabei, dass mehrere TXT-Records am selben Namen problemlos nebeneinander leben. Der neue Record ist einfach neben den SPF-Record und zwei Verifizierungs-Records gerutscht, ohne dass ich irgendetwas anfassen musste.

Einen Bluesky-Claim habe ich bewusst weggelassen. Meine did:plc:... hängt ohnehin schon über einen DNS-TXT-Record und /.well-known/atproto-did an der Domain. Wer den Domain-Claim prüft, hat Bluesky damit transitiv mit erledigt. Noch ein Claim mehr wäre nur mehr Fläche ohne mehr Aussage.

GitHub: ein öffentlicher Gist namens proof.md

Für GitHub braucht es einen öffentlichen Gist. Die Datei darin muss proof.md heißen, der Inhalt ist eine einzige Zeile:

openpgp4fpr:45FCD081ADB54872EA5B06B9893DE0CDDE986DEB

Das geht auch ohne Browser, mit der GitHub-CLI:

gh gist create --public --desc "OpenPGP identity proof (Keyoxide / Ariadne)" proof.md

Eine Abgrenzung, über die regelmäßig jemand stolpert, deshalb explizit: Einen GPG-Schlüssel im GitHub-Profil zu hinterlegen, damit signierte Commits als „Verified“ angezeigt werden, ist eine völlig andere Sache. Anderer Mechanismus, anderer Zweck, und mit dem Keyoxide-Proof hat das nichts zu tun. Der hinterlegte Schlüssel wird von GitHub automatisch für die Commit-Verifikation benutzt. Einen Schalter „diesen Schlüssel als Signing Key verwenden“ gibt es für GPG-Schlüssel übrigens gar nicht, den gibt es nur für SSH-Schlüssel.

Matrix: der Proof ist eine Nachricht

Bei Matrix hat der Proof wieder eine ganz andere Form. Er ist weder eine Datei noch ein DNS-Record, sondern eine Nachricht in einem öffentlichen Raum. Man postet in #doipver:matrix.org schlicht:

openpgp4fpr:45FCD081ADB54872EA5B06B9893DE0CDDE986DEB

Und zeigt dann in der Notation auf genau diese Nachricht. Beim Formatieren gibt es drei Kleinigkeiten, die man garantiert einmal falsch macht: Beim User wird das führende @ weggelassen, bei der Raum-ID das führende ! und bei der Event-ID das führende $. Aus @kernel-error:kernel-error.com wird also u/kernel-error:kernel-error.com, aus !dBfQZxCoGVmSTujfiv:matrix.org wird der nackte Rest, und genauso bei der Event-ID.

Wichtig ist außerdem, dass der Raum unverschlüsselt ist. Eine verschlüsselte Nachricht kann ein Prüfer nicht lesen, damit wäre der Proof wertlos. #doipver ist genau dafür da und entsprechend offen.

Der ganze Vorgang lässt sich komplett über die Client-Server-API erledigen, also POST /_matrix/client/v3/join/... und danach PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}. Die Antwort auf das Senden enthält direkt die event_id, also genau das, was die Notation braucht. Das ist elegant, wenn man es scripten will.

Ehrlicherweise muss man dazu aber sagen: Ein Access-Token gibt volle Kontrolle über den Account. Das aus der Hand zu geben, nur um eine einzige öffentliche Zeile abzusetzen, ist ein schlechtes Geschäft. Der Weg über Element von Hand kostet dreißig Sekunden: Nachricht posten, „Teilen“ beziehungsweise Permalink aufrufen, und Raum-ID sowie Event-ID direkt aus der matrix.to-URL ablesen. Wer trotzdem ein Token benutzt, sollte das Gerät danach abmelden.

Die Claims in den Schlüssel schreiben

Das eigentliche Eintragen passiert in gpg --edit-key. Sieht harmlos aus, hat aber gleich in Zeile zwei die erste Falle:

gpg --edit-key 0x893DE0CDDE986DEB
uid 1
notation
proof@ariadne.id=dns:kernel-error.de?type=TXT
save

Entfernen geht über notation und dann den gleichen Ausdruck mit einem führenden Minus, none löscht alle. Und weil man sich auf Anzeigen ungern verlässt, hier der Blick auf das, was wirklich im Schlüssel steht:

gpg --export 0x893DE0CDDE986DEB | gpg --list-packets | grep notation

Falle 1: uid 1 ist nicht optional

Ohne vorher eine UID auszuwählen schreibt gpg die Notation in jede UID. Bei mir also auch in die Foto-UID. Keyoxide prüft dann denselben Claim zweimal und zeigt ihn doppelt an. Die offizielle Dokumentation erwähnt das im Nebensatz, überlesen ist es schnell, und aufräumen darf man es anschließend von Hand.

Falle 2: jede Änderung schreibt die Selbstsignatur neu

Das ist der eigentliche Preis, und davor warnt einen niemand. Ein Claim mehr bedeutet eine neue Selbstsignatur, und damit ist jede veröffentlichte Kopie des Schlüssels veraltet. Konkret hieß das bei mir jedes Mal: neu hochladen zu drei Keyservern, zwei selbst gehostete Dateien überschreiben und noch eine DNSSEC-Zonenbearbeitung für den DANE-Record hinterher.

Wer drei Claims einzeln nacheinander setzt, macht diese Runde dreimal. Also: Claims sammeln und in einem Rutsch eintragen. Nebenbei wächst der Schlüssel auch spürbar. Die DANE-rdata ist im Lauf des Tages von 909 auf 1130 Byte gewachsen, die WKD-Datei von 12703 auf 12940 Byte. Nichts Dramatisches, aber bei einem DNS-Record schaut man da schon hin.

Falle 3: drei Caches, drei vorgetäuschte Fehlschläge an einem Nachmittag

Das ist der Teil, bei dem vermutlich jeder Leser nickt. An einem einzigen Nachmittag haben mir drei verschiedene Caches je einen kaputten Rollout vorgespielt. In allen drei Fällen war das Deployment in Ordnung und die Prüfung hat gelogen.

Der eigene Resolver. Nach dem Setzen des TXT-Records schlug die lokale Prüfung immer weiter fehl. Google, Cloudflare und Quad9 hatten den neuen Record sofort. Der veraltete Eintrag kam ausgerechnet von meinem eigenen DoT/DoH-Resolver, auf den systemd-resolved zeigt, und der hielt die alte Antwort noch etwa zwei Stunden fest. Das sieht exakt aus wie ein kaputtes Deployment und kostet echte Debugging-Zeit.

ssh root@ns1 "rndc flushname kernel-error.de resolver"
resolvectl flush-caches

Der View-Name am Ende ist bei rndc flushname laut Handbuch optional. In meinem Setup mit einer eigenen resolver-View ist er aber Pflicht, sonst passiert schlicht nichts Sichtbares. Die allgemeine Lehre daraus: Wer eigene DNS-Änderungen prüft, fragt immer einen autoritativen Server und einen öffentlichen Resolver, niemals nur den Systemresolver.

Ein HTTP-Cache vor keyserver.ubuntu.com. Der lieferte einen veralteten Schlüssel mit nur der Hälfte der Claims aus. Ein angehängter Cache-Busting-Parameter brachte sofort die korrekte Version, und ein erneuter Upload wurde mit "ignored" quittiert. Der Server war also die ganze Zeit aktuell, nur der Cache davor nicht.

Und die Keyserver selbst. Keyserver mischen Selbstsignaturen, sie ersetzen sie nicht. Nach einem Update trägt die Keyserver-Kopie die alte und die neue Selbstsignatur. Ein naives grep -c notation zählt dann fünf Notationen, während die selbst gehostete Kopie drei zeigt. gpg nimmt beim Import die neueste, praktisch ist das also harmlos, aber jedes Verifikationsskript lügt einen dabei an. Man prüft besser, was ein frischer Import auflöst, nicht was im rohen Blob steht.

Die Moral aus allen drei Fällen ist dieselbe: gegen eine autoritative Quelle prüfen und gegen eine cache-busted Anfrage, niemals nur gegen die eine bequeme Quelle, die gerade zur Hand ist.

Falle 4: die Gist-Raw-URL springt den Host

Ein kurzer Schreckmoment mit einfacher Ursache. Die Adresse https://gist.github.com/<user>/<id>/raw/proof.md antwortet mit einem 301 auf gist.githubusercontent.com. Ein curl ohne -L liefert damit gar nichts zurück, was auf den ersten Blick nach einem kaputten Proof aussieht. Ist es nicht, es fehlt nur ein Buchstabe am Kommando.

Falle 5: –print-dane-records gibt es nicht mehr

In GnuPG 2.4 ist --print-dane-records weg. Der Aufruf bricht mit einem Fehler ab und verweist auf --export-options export-dane. Nur reicht export-minimal allein nicht: Die Foto-UID bleibt drin und bläst einen DANE-Record von rund einem Kilobyte auf etwa zwölf Kilobyte auf. Die kompakte Variante braucht zusätzlich einen Export-Filter:

gpg --export-filter "keep-uid=mbox = kernel-error@kernel-error.com" --export-options export-dane,export-minimal --export 0x893DE0CDDE986DEB

Der fünfte Claim: Fediverse, und warum er nicht live ist

Jetzt der ehrliche Teil. Ein fünfter Claim war gebaut, ausgerollt, nie verifiziert und noch am selben Tag wieder zurückgebaut. Ich schreibe das hier hin, weil vier aufgeräumte grüne Haken nichts erzählen und dieser eine Fehlschlag mehr über die Technik verrät als der Rest zusammen.

Der Plan war ein Claim auf https://www.kernel-error.de/@kernel-error.de, also den ActivityPub-Actor dieses Blogs, mit dem Proof in den Profil-Metadaten. Beim WordPress-ActivityPub-Plugin werden die „Extra Fields“ als PropertyValue-Attachments an den Actor gehängt. Zwei Entscheidungen dabei waren richtig und bleiben es auch nach dem Rückbau.

Erstens: Die Claim-URL leitet im Browser um, liefert aber unter Accept: application/activity+json sauber den Actor aus. „Mach das doch mal im Browser auf“ ist bei einem maschinenlesbaren Endpunkt schlicht kein gültiger Test. Wer so prüft, verwirft eine funktionierende Adresse.

Zweitens: Die Platzierung war wichtiger als gedacht. Keyoxide akzeptiert den Proof in der Bio, in einem Beitrag oder in den Profil-Metadaten. Bei einem WordPress-Blog-Actor ist die Bio aber der Blog-Slogan, und der steht auf jeder einzelnen Seite der öffentlichen Webseite. Ein Fingerprint im Header, weil man die erstbeste dokumentierte Möglichkeit genommen hat. Die Profil-Metadaten sind genauso gültig und für Leser unsichtbar. Lohnt sich also, erst alle Optionen zu lesen und dann zu wählen.

Ein Implementierungsdetail mit echter Fallhöhe: Das Feld wurde mit $wpdb->insert eingetragen, ausdrücklich nicht mit wp_insert_post(). Letzteres feuert die ActivityPub-Hooks und erzeugt einen Update-Eintrag in der Outbox, der dann als Geisterbeitrag durch das Fediverse geht. Kontrolliert habe ich das über den Zeilenstand der ap_outbox vor und nach dem Eingriff: 130 zu 130, sowohl beim Anlegen als auch beim Löschen. Danach Caches leeren, und zwar zuerst Redis, dann FastCGI, sonst füllt sich der FastCGI-Cache sofort wieder aus den alten Redis-Daten. Wer mehr über das Plugin-Innenleben lesen mag, findet in meinem Beitrag zum eigenen ActivityPub-Plugin für Grav die Mechanik dahinter.

Und dann zeigte Keyoxide trotzdem ein rotes Kreuz.

Ein rotes Kreuz und eine sehr überzeugende falsche Fährte

Der Claim stand auf dem Profil als rotes Kreuz mit der Beschriftung [---], was so viel heißt wie „kein Service Provider hat gematcht“. Die anderen vier waren grün. Das Access-Log sah so aus:

OPTIONS /@kernel-error.de                     301
GET     /.well-known/nodeinfo                 200
GET     /wp-json/activitypub/1.0/nodeinfo/2.1  200
GET     /@kernel-error.de/api/config           404   (doipjs/2.1.0)

Die naheliegende Lesart ist falsch, und sie ist verführerisch. /api/config ist ein Owncast-Endpunkt. Man denkt sofort: Keyoxide hält meinen Claim für einen Owncast-Server, da ist die Provider-Erkennung kaputt. Stimmt aber nicht. Die beiden NodeInfo-Abfragen darüber stammen aus dem ActivityPub-Postprozessor von doipjs, ActivityPub hatte also gematcht. Die Owncast-Abfrage ist der Fallback, der danach läuft, weil der ActivityPub-Test bereits gescheitert war. Symptom, nicht Ursache.

Die Ursache steht in der ersten Zeile. WordPress beantwortet ein OPTIONS auf die hübsche Actor-URL mit einem 301 auf die Startseite. Und ein umgeleiteter CORS-Preflight ist in allen heute relevanten Browsern ein harter Fehler. Die Fetch-Spezifikation ist an der Stelle inzwischen weniger strikt formuliert, in der Praxis bricht der Browser trotzdem ab, und der Prüfer kommt nie an den Actor heran. Das Ärgerliche daran: Die CORS-Header waren die ganze Zeit korrekt gesetzt, Access-Control-Allow-Origin: * und der ganze Rest. Sie kamen nur mit Status 301, und damit sind sie wertlos.

Eine andere Claim-URL hilft nicht heraus. /@handle und /@handle/ antworten beide mit 301 auf OPTIONS, und die eigentliche Actor-ID /?author=0 beantwortet OPTIONS mit 405 und ganz ohne CORS-Header. Es musste also in den nginx, mit einer Location, die den Preflight selbst beantwortet:

location ~ ^/@[^/]+/?$ {
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin  "*"                                  always;
        add_header Access-Control-Allow-Methods "GET, OPTIONS"                        always;
        add_header Access-Control-Allow-Headers "*"                                   always;
        add_header Access-Control-Max-Age       86400                                 always;
        return 204;
    }
    try_files $uri $uri/ /index.php?$args;
}

Platziert als erste Regex-Location, damit sie vor den späteren gewinnt, und der Nicht-OPTIONS-Zweig spiegelt exakt das, was auch location / macht, damit normaler Verkehr unverändert durchläuft. Der Preflight ging damit von 301 auf 204.

Und der erste Fix reichte nicht, das Log hat es leise gesagt

Nach dem Reload kam sauber der 204. Aber das anschließende GET /@kernel-error.de tauchte im Access-Log überhaupt nicht auf, und doipjs marschierte direkt wieder zur Owncast-Abfrage weiter. Genau diese Abwesenheit ist die Fehlermeldung: Ein Preflight, der erfolgreich ist und auf den dann gar keine Anfrage folgt, bedeutet, dass der Browser die Antwort auf den Preflight abgelehnt hat.

Die Liste der erlaubten Header war schlicht geraten. Accept, Authorization, Content-Type klingt vernünftig, deckte aber offensichtlich nicht ab, was doipjs tatsächlich schickt. Die Erweiterung auf Access-Control-Allow-Headers: * war die zweite Hälfte des Fixes. Der Wildcard wirkt hier, weil die Anfrage ohne Credentials und ohne HTTP-Auth läuft. Bei einer Anfrage mit Credentials wäre der Stern wirkungslos, und Authorization deckt er ohnehin nie ab, den müsste man immer namentlich nennen. Oben steht schon die korrigierte Fassung.

Noch eine Warnung für alle, die so etwas nachbauen: Access-Control-Max-Age: 86400 heißt, dass der Browser die alte Preflight-Antwort einen Tag lang behält. Ein normales Neuladen leert den CORS-Preflight-Cache nicht. Also im privaten Fenster nachtesten, sonst jagt man ein Gespenst.

Drei Lehren daraus, die weit über Keyoxide hinaus gelten:

  • Richtige Header auf einem falschen Statuscode sind trotzdem kaputt. Ein 301 mit perfekten CORS-Headern scheitert genauso hart wie gar keine Header.
  • Ein Log liest man von oben nach unten, nicht von unten nach oben. Die verdächtigste Zeile, der 404, war die Folge. Der langweilige 301 zwei Zeilen darüber war der Fehler.
  • Eine Anfrage, die im Log fehlt, ist auch ein Befund. Die zweite Runde habe ich ausschließlich aus etwas diagnostiziert, das nicht passiert ist.

Ein Nebeneffekt, der mir erst hinterher aufging: Das war nie ein Keyoxide-Problem. Jeder Cross-Origin-Konsument meines ActivityPub-Actors, der dafür im Browser läuft und einen Preflight braucht, ist an diesem Redirect gescheitert, seit es die Adresse gibt, und es ist niemandem aufgefallen. Serverseitige Abrufer waren nie betroffen, die kennen kein CORS. Der nginx-Fix ist deshalb geblieben, auch nachdem der Claim wieder weg war. Er repariert einen echten Defekt, völlig unabhängig von Keyoxide.

Und trotzdem blieb es rot

Nach dem CORS-Fix zeigte das Log endlich das gewünschte Bild: OPTIONS ... 204, gefolgt von GET /@kernel-error.de 200 6915 "doipjs/2.1.0". Der Actor wurde geholt. Und der Claim blieb rot. Der Reihe nach ausgeschlossen habe ich dann:

  • Provider-Matching. Das Regex in activitypub.js ist ein Catch-All, und die NodeInfo-Abfragen im Log beweisen ohnehin, dass der zugehörige Postprozessor gelaufen ist.
  • Feldplatzierung. attachment.value ist eines der drei Felder, die doipjs durchsucht.
  • Inhalt und Schreibweise. Fingerprint einmal in Großbuchstaben, einmal in Kleinbuchstaben probiert. Beides ohne Treffer. Die anderen vier Claims benutzen alle Großbuchstaben und verifizieren einwandfrei, an der Schreibweise liegt es also nicht.

Bleibt ein Verdacht, den ich ausdrücklich als unbewiesen kennzeichne: Das hier ist ein WordPress-ActivityPub-Blog-Actor. NodeInfo meldet software: wordpress, und der preferredUsername lautet kernel-error.de, ist also ein Benutzername mit Punkten darin, den Mastodon so gar nicht erzeugen könnte. Das ist ein deutlich weniger befahrener Pfad durch doipjs als ein normales Mastodon-Profil. Weiterzukommen hätte bedeutet, fremdes JavaScript zu debuggen, für den fünften von fünf Claims.

Also habe ich aufgehört, und die Entscheidung gehört zur Geschichte dazu. Rückbau komplett: Notation aus dem Schlüssel entfernt, alle sieben Kanäle neu ausgerollt, das Profilfeld gelöscht, Caches geleert. Der Claim ist damit vollständig verschwunden, es bleibt also kein dauerhaftes rotes Kreuz stehen. Das ist übrigens eine erfreuliche Eigenschaft: Ein Claim, der nicht funktioniert, lässt sich sauber zurückziehen. Nur die Historie der Notation liegt für immer auf den Keyservern. Wer die rohen Schlüsseldaten auseinandernimmt, findet die alten Selbstsignaturen samt Claim also weiterhin. Im aktuellen Profil taucht er nicht mehr auf.

Zwei Stolpersteine aus dem Rückbau, die man kennen sollte:

  • Das Entfernen einer Notation braucht eine Bestätigung. Die Reihenfolge ist uid 1, dann notation, dann der Ausdruck mit führendem Minus, dann ein y, dann erst save. Ohne das y schluckt gpg das nachfolgende save als Antwort auf seine Rückfrage, und das Entfernen passiert stillschweigend gar nicht.
  • Keyserver behalten jede Generation von Selbstsignaturen. Nach dem Rückbau lagen auf keys.openpgp.org zehn Selbstsignaturen auf dieser UID, mit der Reihe 4, 5, 4, 3, 2, 0 Claims. Harmlos, weil gpg wie openpgp.js die neueste nimmt. Aber --export-options export-clean reduziert das nicht auf eine Selbstsignatur. Wer Notationen über einen so bereinigten Export zählt, zählt zu hoch und hält einen erfolgreichen Rückbau für gescheitert. Besser die Zeitstempel pro Signatur vergleichen.

Falle 6: ein langsamer Matrix-Raum sieht aus wie kaputte Föderation

Zum Schluss noch eine schöne Fehldiagnose. Der erste Versuch, #doipver:matrix.org beizutreten, starb mit ResponseNeverReceived. Das liest sich wie ein glasklarer Föderationsausfall, und ich habe entsprechend angefangen zu suchen. Nur: federationtester.matrix.org meldete FederationOK: true mit gültigen Zertifikaten über IPv4 und IPv6, und blankes TCP wie auch HTTPS vom Server nach matrix.org liefen einwandfrei.

#doipver ist einfach ein großer, stark föderierter Raum. Der Alias löst auf hunderte beteiligte Server auf, und ein make_join gegen einen einzelnen ausgelasteten Server dauert entsprechend. Geholfen haben zwei Dinge: mehrere Server zum Beitreten mitgeben und einen großzügigen Timeout setzen, 180 Sekunden waren genug. Der Parameter heißt in der aktuellen Spezifikation via, ich habe hier noch den älteren Namen server_name benutzt.

POST /_matrix/client/v3/join/%21dBfQZxCoGVmSTujfiv%3Amatrix.org
     ?server_name=matrix.org&server_name=privacyguides.org&server_name=monero.social

Die übertragbare Lehre, gerade für dieses Publikum: Bevor man aus einer einzigen gescheiterten Anfrage eine Föderationsstörung diagnostiziert, sollte man prüfen, ob die Anfrage nur langsam war. Am selben Tag tauchten übrigens noch zwei 429 Too Many Requests bei Schlüsselabfragen im Log auf, ebenfalls reine Ablenkung.

Selbst nachprüfen, ganz ohne die Webseite

Und hier kommt der Punkt, an dem das ganze Modell überzeugt. Du musst keyoxide.org nicht vertrauen, du kannst die komplette Prüfung selbst machen. Vier Schritte, gegen meinen Schlüssel:

# 1. Schlüssel holen, so wie es ein Fremder tun würde
gpg --auto-key-locate clear,wkd --locate-external-keys kernel-error@kernel-error.com

# 2. Claims aus der Selbstsignatur lesen
gpg --export 0x893DE0CDDE986DEB | gpg --list-packets | grep 'proof@ariadne.id'

# 3. Claims auflösen
dig +short TXT kernel-error.de   | grep openpgp4fpr
dig +short TXT kernel-error.com  | grep openpgp4fpr
curl -sSL https://gist.github.com/Kernel-Error/b51b81489a0600908cf09aca13765b9c/raw/proof.md
# Matrix: Nachricht im Client öffnen oder das Event per API lesen
#   Raum  !dBfQZxCoGVmSTujfiv:matrix.org
#   Event $q3zhGJTbPWZD5LjUnsnAI9p43YzHXglniNv_SL7dldQ

# 4. Gegen den Fingerprint aus Schritt 1 vergleichen, das ist die gesamte Prüfung

Das ist Keyoxide. Mehr macht die Seite auch nicht, sie macht es nur hübscher und schneller. Wenn du diese vier Schritte einmal von Hand durchgehst, sitzt das Argument „du musst der Webseite nicht vertrauen“ deutlich besser als jede Erklärung.

Was das beweist, und was eben nicht

Damit hier niemand mit falschen Erwartungen rausgeht, die ehrlichen Grenzen:

  • Claims sind praktisch dauerhaft. Sie stehen in der Selbstsignatur, die auf Keyserver hochgeladen wird, und keys.openpgp.org kennt kein Zurücknehmen. Man sollte also nur Identitäten claimen, die man dauerhaft und öffentlich an den Schlüssel gebunden haben möchte. Genau deshalb habe ich Telegram und Threema bewusst weggelassen.
  • Xing und LinkedIn habe ich übersprungen, es gibt dort keinen brauchbaren Proof-Mechanismus. Eine Klarnamen-Bindung existiert bei mir ohnehin in stärkerer Form über die eID-Zertifizierung. Telefonnummern, SIP und Postanschrift sind nicht verifizierbar und ohnehin eine schlechte Idee.
  • Das beweist Kontrolle, nicht Identität. Ein grüner Haken sagt: Dieselbe Instanz kontrolliert diesen Schlüssel und dieses Konto. Er sagt nicht, wer diese Instanz ist. Dafür braucht es weiterhin so etwas wie eine eID-Zertifizierung oder ein persönliches Treffen. Das ist die ehrliche Grenze des ganzen Ansatzes, und sie zu überverkaufen wäre falsch.
  • Die Prüfung ist nur so stark wie der schwächste Endpunkt. Eine DNSSEC-signierte Zone auf eigenen Nameservern ist stark. Ein Gist auf einer Plattform, die morgen ihr URL-Schema ändern kann, ist es nicht.
  • Keyoxide selbst ist eine Bequemlichkeitsschicht. Verschwindet Keyoxide, funktionieren die Claims weiter. Verschwindet GitHub, ist genau dieser eine Claim tot.

Nachtrag: der neue Schlüssel hatte noch ein ganz anderes Problem

Beim Durchleuchten des Setups ist mir nebenbei ein echter Defekt am funkelnagelneuen Schlüssel aufgefallen: Er hatte überhaupt keine Algorithmus-Präferenzen. Die Folge war messbar und wirklich unangenehm. Ein Dritter, der an den alten Schlüssel verschlüsselt hat, bekam AES256. Beim neuen Schlüssel wurde daraus stillschweigend AES128. Der neue, modernere Schlüssel wurde also schwächer adressiert als der, den er ablöst. Das ist eine eigene Geschichte und die erzähle ich in einem eigenen Beitrag, denn der Unterschied zwischen „mein Schlüssel ist modern“ und „mein Schlüssel ist korrekt konfiguriert“ verdient mehr als eine Randnotiz.

Fazit

Das Web of Trust ist als Alltagsmechanismus tot, die Zertifizierungsstellen für Privatpersonen sind weitgehend weg, und trotzdem bleibt die Frage bestehen, wem ein Schlüssel gehört. Identity Claims sind darauf eine überraschend saubere Antwort: kein Account, kein Anbieter, der etwas hält, keine Abhängigkeit von einer Firma, die morgen verkauft wird. Nur zwei Aussagen, die aufeinander zeigen, und ein Schlüssel, der sein Profil selbst ist.

Der Aufwand ist überschaubar, wenn man die Claims sammelt und in einem Rutsch einträgt. Die Zeit geht nicht für Keyoxide drauf, sondern für die eigenen Caches und für einen alten Redirect, den nie jemand hinterfragt hat. So gesehen war der gescheiterte fünfte Claim der nützlichste von allen.

Siehe auch

Wie immer gilt: Wenn etwas unklar ist, wenn ich mich irgendwo irre oder wenn jemand doch noch weiß, warum ein WordPress-ActivityPub-Actor bei doipjs durchfällt, dann dürft ihr mich sehr gerne fragen.

X25519MLKEM768 zerlegt: was in einem Post-Quantum-Handshake wirklich steckt

X25519 und ML-KEM-768 führen ihre Schlüsselanteile in einem hybriden TLS-1.3-Handshake zu gemeinsamem Schlüsselmaterial zusammen.

Wer eine moderne TLS-Verbindung debuggt, stolpert früher oder später über eine Zeichenkette wie X25519MLKEM768. Sie steht im nginx-Log, sie taucht in der Ausgabe von openssl s_client auf, sie klebt in jeder Handshake-Analyse. Und sie sieht aus, als wären da zwei Dinge aus Versehen zusammengeschoben worden. Sind sie aber nicht.

Genau wie die klassischen Cipher Suites folgt auch dieser Name einem klaren Schema. Man muss nur wissen, wo man den Schnitt ansetzt. Vor Jahren habe ich hier schon einmal so eine Zeichenkette auseinandergenommen, damals TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384. Heute ist die Post-Quantum-Welt dran. Ich möchte X25519MLKEM768 einmal komplett durchleuchten: was jeder Teil bedeutet, warum das Ganze so gebaut ist und was es bewusst nicht abdeckt.

Kurze Auffrischung: die vier Teile einer klassischen Cipher Suite

Ein klassischer Suite-Name wie TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 beschreibt vier Bausteine. Der Schlüsselaustausch legt fest, wie sich beide Seiten auf ein gemeinsames Geheimnis einigen, hier ECDHE. Die Authentisierung sagt, womit das Serverzertifikat signiert ist, hier ECDSA. Die eigentliche Verschlüsselung der Nutzdaten macht AES-256-GCM. Und der Hash SHA384 steckt in der Schlüsselableitung und sichert das Handshake-Transkript ab. Die Integrität der übertragenen Nutzdaten übernimmt bei dieser Suite dagegen schon AES-256-GCM selbst, als AEAD-Verfahren braucht es dafür keinen separaten Hash mehr. Wer das im Detail nachlesen mag, findet es im verlinkten Altbeitrag.

Diese vier Slots sind der Rahmen. Post-Quantum betrifft in diesem Beitrag genau einen davon, den Schlüsselaustausch. Die Authentisierung ließe sich ebenfalls quantensicher machen, sie bleibt hier aber vorerst klassisch, dazu am Ende mehr. Der Schlüsselaustausch ist der springende Punkt, den man zuerst verstehen muss.

In TLS 1.3 wandert der Schlüsselaustausch aus dem Namen

Der erste Grund, warum X25519MLKEM768 nicht in der Cipher Suite steht, ist eine Änderung aus TLS 1.3. Dort ist der Schlüsselaustausch aus dem Suite-Namen herausgewandert. Eine TLS-1.3-Suite heißt nur noch TLS_AES_256_GCM_SHA384, also Verschlüsselung plus Hash. Vom Schlüsselaustausch steht da kein Wort mehr.

Stattdessen handeln Client und Server den Schlüsselaustausch separat aus, über die sogenannten Supported Groups. X25519MLKEM768 ist so eine Gruppe. Sie steht neben der Cipher Suite, nicht in ihr. Genau deshalb sitzt Post-Quantum an dieser Stelle: die Sache, die der Quantencomputer bedroht, ist der Schlüsselaustausch, und der wird in TLS 1.3 als eigene Gruppe verhandelt. Die Suite selbst bleibt unangetastet.

Diagramm: Zerlegung der Cipher Suite TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 in Schlüsselaustausch, Authentisierung, Verschlüsselung und Hash, darunter die Abtrennung der Named Group X25519MLKEM768 in TLS 1.3.
Der klassische Suite-String und wie TLS 1.3 den Schlüsselaustausch als eigene Named Group abtrennt.

Warum überhaupt Post-Quantum?

Ein ausreichend großer Quantencomputer bricht mit dem Shor-Algorithmus die Mathematik hinter dem klassischen Schlüsselaustausch. Diffie-Hellman, RSA, die elliptischen Kurven: alles, was auf dem diskreten Logarithmus oder der Faktorisierung großer Zahlen beruht, fällt. Nicht ein bisschen schwächer, sondern gebrochen.

Die symmetrische Seite trifft es weit weniger hart. Gegen AES und die SHA-2-Familie hilft einem Quantencomputer nur der Grover-Algorithmus, und der halbiert lediglich die effektive Schlüssellänge. AES-256 verhält sich gegen Grover ungefähr so wie AES-128 gegen einen klassischen Rechner, und das ist weiterhin weit außerhalb des Machbaren. Deshalb muss der symmetrische Teil der Cipher Suite gar nicht ersetzt werden. Man nimmt die größere Variante, und die Sache ist erledigt. Nur der asymmetrische Schlüsselaustausch braucht wirklich Ersatz.

Der Haken ist der Zeitfaktor. Ein Angreifer, der heute Datenverkehr mitschneidet und wegspeichert, braucht den Quantencomputer nicht heute. Er kann warten. Kommt die Maschine in zehn oder fünfzehn Jahren, entschlüsselt er den alten Mitschnitt rückwirkend. Man nennt das harvest now, decrypt later. Für alles, was auch in fünfzehn Jahren noch vertraulich sein soll, ist die Bedrohung damit schon heute real. Das ist der Grund, warum man nicht wartet, bis es den Quantencomputer gibt.

Was ist ein KEM, und wie unterscheidet es sich von Diffie-Hellman?

Bevor wir den Namen zerlegen, ein Begriff, den man dafür braucht. Diffie-Hellman, auch in seiner elliptischen Variante ECDHE, funktioniert symmetrisch: beide Seiten werfen einen öffentlichen Wert in die Leitung, jede rechnet mit ihrem eigenen geheimen Wert und dem öffentlichen der Gegenseite, und am Ende haben beide dasselbe gemeinsame Geheimnis heraus. Verschickt hat es niemand, es entsteht auf beiden Seiten gleichzeitig.

Ein KEM, ein Key Encapsulation Mechanism, geht anders vor. Es kennt drei Schritte. Eine Seite erzeugt ein Schlüsselpaar und schickt den öffentlichen Teil. Die andere Seite führt mit diesem öffentlichen Schlüssel die Encapsulation aus. Dabei entstehen in einem Schritt zwei zusammengehörige Dinge: ein frisches gemeinsames Geheimnis und ein Chiffretext dazu. Der Chiffretext geht zurück. Die erste Seite entkapselt ihn mit ihrem privaten Schlüssel und hält dasselbe Geheimnis in der Hand. Encapsulate und Decapsulate, daher der Name.

Diagramm: Vergleich von Diffie-Hellman und KEM. Links tauschen beide Seiten öffentliche Werte, rechts schickt der Client einen öffentlichen Schlüssel und der Server antwortet mit einem Chiffretext, Encapsulate und Decapsulate.
Diffie-Hellman gegen KEM: beim KEM erzeugt der Client das Schlüsselpaar, der Server kapselt das Geheimnis ein.

Im TLS-Handshake ist die Rollenverteilung dabei klar. Der Client erzeugt das ML-KEM-Schlüsselpaar und legt den öffentlichen Schlüssel in sein key_share, also gleich in den ClientHello. Der Server nimmt diesen Schlüssel, kapselt ein frisches Geheimnis ein und schickt den Chiffretext in seinem ServerHello zurück. Der Client entkapselt ihn und beide haben dasselbe Geheimnis. Genau deshalb ist es der 1184 Byte große öffentliche Schlüssel, der den ClientHello aufbläht, und nicht der Chiffretext. Klein bleibt der ServerHello damit trotzdem nicht: dort steckt der 1088 Byte lange ML-KEM-Chiffretext plus der 32 Byte lange X25519-Anteil. Beide Seiten schleppen also einen großen hybriden Share. Der Client-Share ist mit 1216 Byte nur rund 96 Byte größer als der 1120 Byte große Server-Share.

Warum nicht einfach Diffie-Hellman mit einem quantensicheren Verfahren? Weil die gitterbasierte Mathematik, auf der ML-KEM beruht, sich nicht sauber in das symmetrische DH-Schema pressen lässt. Die KEM-Form passt zu dem, was Gitter gut können: etwas einkapseln und wieder herausholen. Deshalb ist der neue Standard ein KEM und kein neues Diffie-Hellman. Wer die Denke von ECDHE im Kopf hat, muss hier einmal umschalten.

Der klassische Teil: X25519

Jetzt zum Namen selbst. X25519 ist der Teil, den es schon lange gibt. Es ist Diffie-Hellman über der elliptischen Kurve Curve25519, schnell, seit Jahren im breiten Einsatz und gründlich untersucht. In einer klassischen TLS-1.3-Verbindung macht X25519 den Schlüsselaustausch ganz allein. Sein öffentlicher Anteil ist mit 32 Byte winzig, die Rechnung ist billig, und in Sachen Vertrauen hat es sich über Jahre bewiesen.

Sein einziges Problem ist der Quantencomputer. Gegen Shor hält X25519 nicht. Es allein weiterzuverwenden hieße, sich genau der harvest-now-Bedrohung auszuliefern. Es einfach wegzuwerfen wäre aber auch schade, denn es ist bewährt. Diese Spannung löst der zweite Teil.

Der Post-Quantum-Teil: ML-KEM-768

MLKEM768 ist der neue Teil. ML-KEM steht für Module-Lattice-Based Key Encapsulation Mechanism, das quantensichere KEM, das die NIST im August 2024 als FIPS 203 standardisiert hat. Wem der Name CRYSTALS-Kyber etwas sagt: das ist der Vorgänger, ML-KEM ist die standardisierte Fassung davon.

Die Sicherheit von ML-KEM beruht nicht auf dem diskreten Logarithmus, sondern auf Gitterproblemen, konkret auf dem Module-LWE-Problem. Das ist eine ganz andere mathematische Baustelle, und nach heutigem Kenntnisstand hilft auch ein Quantencomputer dort nicht weiter. Die 768 im Namen ist keine Byte-Angabe, sondern der Parametersatz. Er ist der NIST-Sicherheitskategorie 3 zugeordnet, deren Referenzniveau sich grob am Aufwand eines Angriffs auf AES-192 orientiert. Eine exakte Zahl wie 192 Bit sollte man daraus aber nicht ableiten, die Kostenmodelle für Gitterangriffe und klassische Schlüsselsuche sind nicht dasselbe. Kategorie 3 ist der übliche Mittelweg zwischen dem kleineren ML-KEM-512 und dem größeren ML-KEM-1024.

Man muss die Gittermathematik nicht beherrschen, um die Grundidee zu greifen. Grob gesagt versteckt ML-KEM sein Geheimnis in einem System aus vielen Gleichungen, dem absichtlich ein kleines Rauschen beigemischt wurde. Ohne den privaten Schlüssel lässt sich das Rauschen nicht sauber herausrechnen, und den richtigen Wert trotzdem zu finden, gilt auch für einen Quantencomputer als hart. Shor greift hier ins Leere, weil es weder um Faktorisierung noch um diskrete Logarithmen geht. Das ist der ganze Trick: eine Härte, für die keine Quanten-Abkürzung bekannt ist.

Interessant sind die Größen. Der öffentliche Schlüssel von ML-KEM-768 ist 1184 Byte groß, der Chiffretext 1088 Byte. Zum Vergleich: der X25519-Anteil misst 32 Byte. Das gemeinsame Geheimnis, das am Ende herausfällt, ist bei beiden gleich klein, nämlich 32 Byte. Der Zugewinn an Sicherheit steckt also nicht im Ergebnis, sondern im Aufwand, es auszuhandeln. Diese Größe wird später noch wichtig.

Warum beide zusammen? Der Hybrid-Gedanke

Das Entscheidende an X25519MLKEM768 ist, dass beide Verfahren zugleich laufen. Es ist ein hybrider Schlüsselaustausch. X25519 liefert ein gemeinsames Geheimnis, ML-KEM-768 liefert ein zweites. Diese beiden Geheimnisse werden aneinandergehängt und wandern gemeinsam in den Schlüsselplan von TLS 1.3, also durch die HKDF-Ableitung, aus der am Ende die eigentlichen Sitzungsschlüssel fallen.

Diagramm: Hybrider Schlüsselaustausch. X25519 und ML-KEM-768 liefern je ein 32 Byte langes Geheimnis, beide werden konkateniert und über HKDF zum Sitzungsschlüssel abgeleitet.
Beide Schlüsselaustausche laufen parallel, ihre Geheimnisse landen gemeinsam im TLS-1.3-Schlüsselplan.

So, wie es für X25519MLKEM768 in TLS 1.3 standardisiert ist, bleibt das Ergebnis sicher, solange mindestens einer der beiden Schlüsselaustausche sicher ist. Erst wenn ein Angreifer beide bricht, fällt der Schlüssel. Diese Garantie hängt allerdings an der sauberen Einbindung in den TLS-1.3-Schlüsselplan, bloßes Aneinanderhängen zweier Geheimnisse ist nicht in jedem Protokoll automatisch sicher. Und das ist der ganze Sinn der Übung. ML-KEM ist quantensicher, aber noch jung. Sollte in der Gitterkryptografie doch eine Schwäche gefunden werden, hält immer noch das klassische X25519 die Stellung. Sollte umgekehrt der Quantencomputer kommen, fällt X25519, aber ML-KEM trägt weiter. Man müsste beide gleichzeitig knacken, und das ist mit heutigem Wissen für keine der beiden Seiten in Sicht. Man bekommt die neue Sicherheit, ohne die alte aufzugeben.

Kleine Kuriosität am Rande: im Namen steht X25519 vorne, auf dem Draht und bei der Kombination der Geheimnisse liegt der ML-KEM-Teil zuerst. Der Name folgt einer Konvention, die Byte-Anordnung einer anderen. Fürs Verständnis ist das egal, für eine eigene Implementierung nicht. Der allgemeine Rahmen für hybride Schlüsselaustausche in TLS 1.3 ist übrigens inzwischen als RFC 9954 veröffentlicht. Der konkrete Codepoint X25519MLKEM768 mitsamt seiner genauen Kodierung steckt dagegen noch in einem eigenen Entwurf, draft-ietf-tls-ecdhe-mlkem, den die TLS-Arbeitsgruppe im März 2025 angenommen hat. Zum Zeitpunkt dieses Beitrags ist dieser Teil noch kein veröffentlichtes RFC, er liegt aber schon beim RFC Editor. Im echten Datenverkehr ist er trotzdem längst unterwegs.

Der Preis: der Handshake wird größer

Die 1184 Byte haben eine Nebenwirkung. Ein klassischer ClientHello passt bequem in ein Paket. Packt man den ML-KEM-Schlüssel dazu, wird es eng, und manche fehlerhaften Middleboxes oder ältere Mailserver kommen mit dem größeren ClientHello nicht klar. Bei HTTPS bricht der TLS-Handshake dann in der Regel einfach ab, einen automatischen Rückfall auf Klartext gibt es dort nicht. Kritischer sind opportunistische Protokolle wie SMTP mit STARTTLS: dort kann, je nach lokaler TLS-Policy, nach einem gescheiterten TLS-Versuch tatsächlich unverschlüsselt weiter zugestellt werden. Das ist aber eine Eigenschaft des Anwendungsprotokolls und seiner Policy, nicht von TLS selbst.

Standardmäßig schickt OpenSSL 3.5 den hybriden Schlüssel gleich im ersten ClientHello mit, X25519MLKEM768 ist dort als vorhergesagter Key Share vorgesehen. Genau deshalb passt der ClientHello unter Umständen nicht mehr in ein einzelnes TCP-Segment. Wer das vermeiden will, kann Delayed Key-Share konfigurieren: der Client bietet die Gruppe dann zwar in den Supported Groups an, schickt den großen Share aber noch nicht mit. Bevorzugt der Server sie, fordert er ihn per HelloRetryRequest nach, was eine zusätzliche Rundreise kostet. Bei Postfix habe ich genau diesen Weg über das vorangestellte Fragezeichen in der Kurvenliste eingerichtet und auf dem Draht mit tcpdump nachvollzogen. Der Link steht unten. Für das Verständnis des Namens reicht: der Post-Quantum-Teil ist groß, und der Handshake muss sich darauf einstellen.

Was hier nicht post-quantum ist: die Authentisierung

Ein Punkt wird gern übersehen. X25519MLKEM768 schützt den Schlüsselaustausch, nicht die Authentisierung. Dass man wirklich mit dem richtigen Server spricht und nicht mit jemandem in der Mitte, hängt an zwei klassischen Signaturen. Die Zertifikatskette ist von der CA mit ECDSA oder RSA signiert, und der Server beweist zusätzlich im Handshake mit einer eigenen Signatur über das Transkript, dem CertificateVerify, dass er den passenden privaten Schlüssel besitzt. Beide sind heute noch klassisch, an dieser Stelle steckt also weiterhin nichts Quantensicheres.

Das ist kein Versehen, sondern eine Frage der Reihenfolge. Die quantensicheren Signaturverfahren gibt es durchaus, ML-DSA und SLH-DSA sind standardisiert. Nur bekommt man praktisch von keiner öffentlichen CA heute ein post-quantum signiertes Zertifikat. Die ganze Kette aus Wurzel, Zwischenzertifikat und Serverzertifikat müsste mitziehen, Browser und Betriebssysteme müssten die neuen Wurzeln kennen, und das dauert Jahre.

Warum ist das trotzdem vertretbar? Weil die Bedrohung bei der Server-Authentisierung im TLS-Handshake eine andere ist. Diese Signatur muss vor allem in dem Moment halten, in dem die Verbindung aufgebaut wird. Ein Quantencomputer in fünfzehn Jahren kann eine damals abgeschlossene Verbindung nicht rückwirkend fälschen, sie ist längst vorbei. Harvest now, decrypt later trifft also die Vertraulichkeit, nicht die Echtheit eines vergangenen Handshakes. Andere Signaturen, etwa unter Dokumenten oder Firmware, müssen dagegen oft noch Jahrzehnte halten, das ist ein eigener Fall. Deshalb ist der Schlüsselaustausch das dringende Problem und darf zuerst quantensicher werden. Die Signaturen kommen später, und dafür bleibt mehr Zeit.

Und in der Praxis?

Das alles klingt nach Zukunft, ist aber längst Gegenwart. Ich habe über fünfzehn Tage mitgeloggt, welche Gruppe die Clients auf diesem Blog tatsächlich aushandeln. Ergebnis: rund 57 Prozent aller Handshakes liefen bereits über X25519MLKEM768, bei aktuellen Browsern waren es um die 77 Prozent. Das ist kein Laborwert, sondern normaler Besucherverkehr. Der Schlüsselaustausch, den ich hier zerlegt habe, ist für den Großteil der Verbindungen zu dieser Seite schon der Normalfall.

Sehen kann man das selbst mit einem Blick von außen. Ein openssl s_client -connect host:443 zeigt in seiner Ausgabe die ausgehandelte Gruppe, und wenn dort X25519MLKEM768 steht, dann läuft genau der hybride Schlüsselaustausch, den wir hier auseinandergenommen haben. Auf der Serverseite kann man dieselbe Information über die Log-Variable $ssl_curve mitschreiben, was ich für die Auswertung unten genau so gemacht habe.

Wer das selbst einrichten will, muss vor allem eines wissen: es hängt an der Krypto-Bibliothek, also an OpenSSL 3.5 oder neuer, nicht am Webserver oder Mailserver selbst. Die konkreten Anleitungen für nginx sowie für Postfix und Dovecot habe ich getrennt aufgeschrieben, dazu die Auswertung, aus der die Zahlen oben stammen. Die Links stehen gleich hier drunter.

Kurz zusammengefasst

X25519MLKEM768 ist kein Tippfehler und kein Buzzword, sondern eine saubere Konstruktion. Ein klassischer Schlüsselaustausch und ein quantensicherer laufen parallel, ihre Geheimnisse werden zusammengeführt, und der Handshake ist nur zu knacken, wenn beide fallen. Der symmetrische Teil der Verbindung bleibt klassisch, weil er es sich leisten kann. Die Authentisierung bleibt vorerst auch klassisch, weil sie nicht so eilt. Und wenn man den Namen einmal an der richtigen Stelle auseinandernimmt, steht da nichts Geheimnisvolles mehr, sondern genau das, was drinsteckt.

Siehe auch

Etwas unklar geblieben, anderer Meinung oder eine Ergänzung? Gern einfach fragen.

Wenn PHP beim Aufräumen stirbt: ein FreeBSD-rtld-Bug hinter posix_spawn

Beitragsbild zu einem FreeBSD-Bug: Laptop mit PHP- und lldb-Debug-Ausgaben, Signal-11-Core-Dump und Diagramm der Kausalkette von Nextcloud über proc_open und posix_spawnp bis zur rtld-Heap-Korruption.

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:

lldb --batch -o "target create --core <core> <php-binary>" -o "bt all"

Der Stack sah beim ersten Lauf so aus:

_start → __libc_start1 → main → php_module_shutdown → zend_shutdown
  → zend_hash_graceful_reverse_destroy → destroy_zend_class +1228

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:

php_module_shutdown → zend_interned_strings_dtor
  → zend_hash_destroy +310 → _str_dtor → _efree +11

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:

AufrufSpawn-PfadCrash
proc_open(["true"], …) (relativ)posix_spawnp__libc_execvpe (PATH-Suche)ja
proc_open(["/usr/bin/true"], …) (absolut)posix_spawnpexecvPe direktnein
proc_open("true", …) (String)posix_spawn (/bin/sh -c) → _execvenein

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:

rfork_thread(RFSPAWN, stack + stacksz, _posix_spawn_thr, &psa)

Der Stack für dieses Kind ist ein winziger, per malloc geholter Puffer:

#define _RFORK_THREAD_STACK_SIZE  4096
stacksz = 4096 + MAX(3, argc + 2) * sizeof(char *);   /* 16-Byte aligned */
stack   = malloc(stacksz);

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:

curl -s "https://bugs.freebsd.org/bugzilla/rest/bug/295991"

Upstream pinnt die Ursache

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_bindfind_symdefsymlook_defaultdonelist_init. Und donelist_init macht ein alloca, dessen Größe mit der Zahl der geladenen Shared Objects skaliert:

#define donelist_init(dlp) ((dlp)->objs = alloca(obj_count * sizeof(dlp)->objs[0]), assert((dlp)->objs != NULL), (dlp)->num_alloc = obj_count, (dlp)->num_used = 0)

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.

NB-2020-U Fingerabdruckleser: der libfprint-Patch ist upstream gemergt

Beitragsbild zum NB-2020-U Fingerabdruckleser: Notebook mit Fingerabdrucksensor, libfprint-Codeausschnitt mit Product-ID 0x2020 und grünem Merge-Status für den upstream übernommenen Patch.

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.

Das Warten hat ein Ende. MR !569 ist gemergt.

Was der Maintainer gemacht hat

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.

Siehe auch

Denselben Leser im Notebook oder eine ähnliche Baustelle mit libfprint? Dann einfach fragen.

Tiered Storage live: Wie ein ZFS special vdev den HDD-Flaschenhals an der Wurzel packt

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.

Symbolische Darstellung eines ZFS-HDD-Mirrors mit SSD-special-vdev: Metadaten-I/O wird von Festplatten auf schnelle SSDs ausgelagert.

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:

zpool create -o altroot=/mnt -O compress=lz4 -O atime=off -m none -f zroot mirror ada0p3 ada1p3
  • 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:

=>       40  468862048  ada2  GPT  (224G)
         40       2008        - free -  (1004K)
       2048   33554432     1  freebsd-zfs  (16G)     # p1 -> SLOG
   33556480  435304448     2  freebsd-zfs  (208G)    # p2 -> special
  468860928       1160        - free -  (580K)

Jetzt der eigentliche Akt: gespiegelter SLOG und gespiegeltes special vdev werden hinzugefügt.

zpool add zroot log     mirror ada2p1 ada3p1
zpool add zroot special mirror ada2p2 ada3p2

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:

zroot       mirror-0   ada0p3 + ada1p3   1.80T  (Daten, HDD-Mirror)
            special    mirror-3: ada2p2 + ada3p2   206G  (Metadaten, SSD-Mirror)  NEU
            logs       mirror-2: ada2p1 + ada3p1   15.5G (ZIL/SLOG, jetzt gespiegelt)

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:

                  capacity     operations     bandwidth    total_wait
vdev            alloc   free   read  write   read  write   read   write
mirror-0        1.22T   595G     45      7   434K   601K   46ms   34ms    # HDD (Daten)
  ada0p3                         22      3   217K   300K   56ms   39ms
  ada1p3                         23      3   217K   300K   36ms   29ms
special/mirror-3 5.38G  201G      0     67  5.28K  3.24M  455us    6ms    # SSD (Metadaten)
  ada2p2                          0     33  2.67K  1.62M  447us    5ms
  ada3p2                          0     33  2.61K  1.62M  464us    6ms
logs/mirror-2   31.6M  15.5G      0     45      3   947K    2ms    1ms    # SSD (SLOG/ZIL)

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

Siehe auch:

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.

Von SEO zu AEO, der Kassensturz: was eine maschinenlesbare Identität wirklich bringt

Visualisierung einer maschinenlesbaren Online-Identität mit JSON-LD, Knowledge Graph und KI-Antwortsystemen zur Verknüpfung einer Person über mehrere digitale Quellen.

Am 1. Januar habe ich hier einen Beitrag geschrieben, der eine Wette war. Die These: Web-Optimierung verschiebt sich. Weg von SEO, dem Kampf um die beste Platzierung bei Google, hin zu etwas, das ich AEO genannt habe. Answer Engine Optimization. Also nicht mehr „wie komme ich auf Platz eins“, sondern „wie liefere ich die beste maschinenlesbare Antwort“. Ich habe damals llms.txt eingebaut, ein bisschen über JSON-LD geschrieben und ehrlich dazugesagt, dass niemand weiß, ob das langfristig relevant bleibt. Der letzte Satz war: ich bin gespannt, was passiert.

Jetzt ist ein gutes halbes Jahr vergangen. Zeit für einen Kassensturz. Was davon hat sich gehalten, was war naiv, was hat sich differenziert? Und vor allem: ich habe in den letzten Monaten tatsächlich daran gearbeitet, mich für eine Maschine sauber beschreibbar zu machen. Nicht als Theorie, sondern an der eigenen Seite, mit allen Fehlern, die dabei sichtbar wurden. Genau diese Fehler und die Abwägungen dahinter sind der eigentliche Inhalt dieses Beitrags. Wer den Vorgängerpost noch nicht kennt, findet ihn hier: Von SEO zu AEO, warum llms.txt, JSON-LD und Answer Engines das Web verändern.

Die Suche wird zur Antwortmaschine

Fangen wir mit dem an, was sich gerade wirklich verändert, unabhängig von meinem Blog. Wer heute etwas googelt, bekommt immer öfter die Antwort direkt auf der Ergebnisseite. Eine zusammengefasste KI-Antwort, darunter vielleicht ein paar Quellen. Der Klick auf eine Webseite entfällt. Dafür gibt es einen Begriff: Zero-Click-Suche. Die Information erreicht den Menschen, ohne dass er die Seite besucht, von der sie stammt.

Das ist keine Vermutung, das lässt sich messen. Das Pew Research Center hat Daten aus dem Frühjahr 2025 ausgewertet, veröffentlicht im Juli 2025: das Surfverhalten von rund 900 erwachsenen US-Nutzern, knapp 68.900 Google-Suchanfragen. Das Ergebnis: bekamen die Leute eine KI-Zusammenfassung angezeigt, klickten nur noch 8 Prozent auf einen weiterführenden Treffer. Ohne KI-Zusammenfassung waren es 15 Prozent, fast doppelt so viel. Auf die in der KI-Antwort verlinkten Quellen klickte überhaupt nur 1 Prozent. Fairerweise dazugesagt: Google hält die Methodik dieser Studie für nicht repräsentativ, hat aber keine eigenen Gegenzahlen vorgelegt. Zur Einordnung der Größenordnung: schon bei der ganz normalen Google-Suche endet ein großer Teil ohne Klick. Die Zero-Click-Studie von SparkToro (Rand Fishkin, 2024, Datenbasis Datos, das zu Semrush gehört) kommt auf rund 58 Prozent in den USA und knapp 60 Prozent in der EU, neuere Auswertungen für 2026 eher Richtung zwei Drittel. Und ein Hinweis zur Vorsicht, weil die Zahl gern falsch zitiert wird: die oft genannten 93 Prozent Zero-Click gelten ausschließlich für Googles AI Mode, also den dialogorientierten Chat-Modus der Suche (Semrush maß dort 92 bis 94 Prozent), nicht für die normale Suche. Wer diese Schlagzeile unbesehen übernimmt, vergleicht Äpfel mit Birnen.

Wie stark der Effekt kausal ist, hat ein randomisiertes Feldexperiment von Saharsh Agarwal (Indian School of Business) und Ananya Sen (Carnegie Mellon University) untersucht, Feldphase Januar und Februar 2026, 1.065 ausgewertete US-Desktop-Nutzer von Chrome. Auf den Suchanfragen, bei denen tatsächlich eine KI-Übersicht erschien, sanken die organischen Klicks um etwa 38 Prozent, die Zero-Click-Rate stieg von 54 auf 72 Prozent. Wichtig für die Einordnung: das ist ein noch nicht begutachtetes Arbeitspapier, online seit April 2026, und die Stichprobe sind aktive Desktop-Chrome-Nutzer aus einem Panel, nicht alle Google-Nutzer. Das Studiendesign war immerhin vorab registriert, was die Aussagekraft stützt. Trotzdem bleibt es ein Befund mit klaren Grenzen. Die Pointe ist nicht, dass die Suche stirbt, sondern etwas Nüchterneres: Sichtbarkeit entkoppelt sich vom Klick. Man kann als Quelle einer Antwort auftauchen, ohne dass jemand die eigene Seite öffnet.

Vom String zum Ding

Jetzt wird es interessant, denn hier liegt der Mechanismus, der mitentscheidet, ob man in so einer Antwort überhaupt vorkommt. Schon 2012 hat Google den Knowledge Graph eingeführt, unter dem Slogan „Things, not strings“. Übersetzt: Dinge, nicht Zeichenketten. Davor war eine Suchmaschine im Kern ein Textabgleich. Du tippst Buchstaben, sie sucht Seiten mit denselben Buchstaben. Seitdem versucht Google, hinter den Buchstaben das tatsächliche Ding zu erkennen. Eine Entität. Ein eindeutig identifizierbares Etwas mit Beziehungen zu anderen eindeutig identifizierbaren Etwas.

Das klassische Beispiel ist das Wort Jaguar. Tier, Auto oder Betriebssystem? Ein Mensch erkennt aus dem Zusammenhang sofort, was gemeint ist. Eine Maschine muss disambiguieren, also die Mehrdeutigkeit auflösen. Und genau dasselbe Problem gilt für mich. Welcher Sebastian van de Meer? Es gibt mehr als einen Menschen mit diesem Namen. Für eine Maschine ist mein Name erst einmal nur eine Zeichenkette, die zu mehreren Personen passt. Eindeutigkeit wird belohnt. Es gibt dazu einen vielzitierten Datenpunkt, den ich ehrlich einordnen muss: laut einer Auswertung von Kalicube (Jason Barnard, veröffentlicht bei Search Engine Land im August 2025) verschwanden im Juni 2025 über drei Milliarden Einträge aus dem Knowledge Graph, ein Rückgang von rund sechs Prozent, verteilt auf zwei Stichtage. Google hat das nie offiziell bestätigt, und die Deutung, dass hier Klarheit über Masse gewinnt, ist die des Analysten, nicht Googles erklärter Grund. Also ein Indiz, kein Gesetz. Der Knowledge Graph selbst speist sich nach Googles eigenen Angaben unter anderem aus Wikipedia, Branchenquellen nennen ergänzend Wikidata, und er ist das Bindeglied zwischen klassischer Suche und KI-Antworten. Googles KI-Suche, die auf Gemini basiert, greift nach eigener Darstellung auf den Knowledge Graph als Echtzeit-Quelle zurück. Wer dort als saubere Entität existiert, ist für beide Welten greifbar.

Wie man sich einer Maschine als Entität vorstellt

Damit sind wir beim Herzstück. Wie sage ich einer Maschine glaubwürdig, wer ich bin? Das Werkzeug dafür heißt JSON-LD nach dem schema.org-Vokabular. Vereinfacht: ein maschinenlesbarer Steckbrief, der direkt in der Seite liegt und Fakten ausdrücklich beschriftet. Das ist der Autor, das ist sein Beruf, das ist das Erscheinungsdatum. Statt die Maschine alles aus Fließtext erraten zu lassen, legt man ihr die Fakten getypt hin. Eine Klarheits- und Extraktionshilfe, mehr nicht. Keine Garantie auf ein Ranking und keine Garantie, zitiert zu werden. Diese Erwartung muss man sofort dämpfen, sonst baut man Luftschlösser.

Aus der abstrakten Ansage von damals ist bei mir eine ziemlich durchdachte Identitäts-Architektur geworden. Und ehrlich: das Spannende waren nicht die Zeilen, die ich geschrieben habe, sondern das, was ich dabei gelernt habe. Neun Punkte, die ich so vorher nicht auf dem Schirm hatte.

Erstens, eine Identität, eine kanonische Adresse. Für eine Maschine sollte eine Person genau ein Ding sein, mit einer stabilen Kennung, die überall identisch auftaucht, nicht auf jeder Seite neu erfunden. Maschinen lösen Identität über stabile Identifier auf, nicht über Namen. Lose Namensnennungen ohne gemeinsame Kennung werden als verschiedene Menschen gelesen oder gar nicht zusammengeführt. Der Preis ist weniger Flexibilität. Der Gewinn ist ein zusammenhängender Knoten statt vieler Splitter.

Zweitens, eine Wahrheitsquelle statt überall dasselbe reinkippen. Die vollständige Selbstbeschreibung steht bei mir an genau einer Stelle, auf der Über-mich-Seite. Alle anderen Seiten tragen nur eine schlanke Referenz darauf. Der Grund ist unromantisch: dieselbe Definition überall zu duplizieren erzeugt Drift. Man ändert eine Stelle, vergisst die anderen, und am Ende widerspricht sich der eigene Datensatz selbst. Die Abwägung: die schlanke Referenz darf nicht zu dünn sein, sonst findet ein Crawler, der zufällig nur eine Artikelseite erwischt, keinen Anker zurück zum Profil.

Drittens, Privates gehört nicht in den maschinenlesbaren Broadcast. Das war für mich die wichtigste Einsicht. Telefonnummer, Adresse und ähnliches haben für die maschinelle Identifikation exakt null Wert. Disambiguiert wird über verlinkte Profile, nicht über die Handynummer. Auf jeder einzelnen Seite ausgestrahlt wären solche Daten dagegen eine ideale Fläche zum Abgreifen. Also stehen die privaten Angaben jetzt bewusst nur dort, wo sie hingehören, und sind nicht mehr auf rund 470 Seiten als sauber beschriftete Schlüssel-Wert-Paare maschinenlesbar verteilt. Das ist die zentrale Abwägung zwischen Datenschutz und Maschinenlesbarkeit, und sie fällt klar zugunsten Datenschutz aus. Das Schöne: man verliert dabei kein einziges Identitäts-Signal.

Viertens, externe Anker sind die eigentliche Beweiskette. Eine Behauptung über mich wird erst dann prüfbar, wenn sie auf unabhängige Profile verweist und diese zurückverweisen. Bei mir sind das unter anderem GitHub, ein Eintrag im BSI-Bürger-CERT-Netzwerk, Mastodon und die Bluesky-Brücke, dazu Identifier wie ORCID und ein Wikidata-Eintrag. Entscheidend ist die Wechselseitigkeit. Ein Verweis zählt nur, wenn die Gegenseite zurückzeigt. Anfangs lagen diese Anker nur auf der Profilseite. Das war ein Single Point of Failure: wenn ein Crawler genau diese eine Seite nicht erwischt, ist die Identität nicht mehr belegbar. Also gehören die stärksten Anker auf jede Seite. Und die Disziplin dabei: unverifizierbare oder tote Profile lässt man weg, weil sie das Signal nur verwässern.

Fünftens, innere Widerspruchsfreiheit ist selbst ein Qualitätssignal. Ein Beispiel aus der eigenen Seite, das mich erst geärgert und dann überzeugt hat: der Herausgeber des Blogs und der Herausgeber der einzelnen Artikel zeigten auf zwei verschiedene, nirgends sauber definierte Stellen. Für eine Maschine sieht so etwas aus wie ein Datenfehler und untergräbt das Vertrauen in den gesamten Datensatz. Die Lektion war, lieber einen sauber benannten zusätzlichen Knoten einzuführen, hier die Marke „Kernel-Error“ als eigene Herausgeber-Instanz, als zwei sich widersprechende Halbwahrheiten stehen zu lassen. Das ist übrigens keine technische Petitesse, sondern eine echte Identitäts-Entscheidung: gilt „Kernel-Error“ als eigene Marke neben der Person? Ich habe mich dafür entschieden, und plötzlich ergab der ganze Rest Sinn.

Sechstens, einen Wissensgraphen kann man nicht belügen. Das klingt pathetisch, ist aber sehr praktisch gemeint. Alle externen Quellen, auf die ich verweise, sind crawlbar. Jeder Status lässt sich gegen das echte Upstream-Projekt prüfen. Also habe ich offene Beiträge ehrlich als offen gekennzeichnet, statt sie als erledigt zu verkaufen. Zwei meiner Patches für eine Fingerabdruckleser-Bibliothek sind eingereicht, aber noch nicht gemerged, und genau so steht es da. Behauptungen, die ich nicht belegen kann, etwa angebliche CVEs, die sich öffentlich nicht auffinden lassen, habe ich komplett weggelassen. Ein als „erledigt“ deklarierter, in Wahrheit offener Beitrag ist ein sofort widerlegbarer Fehler, und der beschädigt die Glaubwürdigkeit des gesamten Profils. Die Abwägung ist unbequem: das Profil sieht weniger beeindruckend aus. Aber ein einziger entlarvter Fake-Claim ist teurer als zehn ehrliche kleine. Vertrauen entsteht aus Prüfbarkeit, nicht aus Behauptung.

Siebtens, Expertise belegt man mit Artefakten, nicht mit Adjektiven. Niemand muss mir glauben, dass ich etwas kann. Sie können es nachsehen. Konkrete, von Dritten kontrollierbare Arbeiten sind der stärkste maschinenlesbare Beleg. Ein in ein fremdes Projekt aufgenommener Patch verankert mich im Linkgraph dieses fremden, autoritativen Projekts. Ein eigenes Repository ist überprüfbarer Code, kein Selbstlob. Die Disziplin dahinter: nur real Existierendes, korrekt zugeschrieben. Fremde Maintainer-Arbeit führe ich nicht als meine. Bei einem Rezensions-Artikel über ein Tool, das mir nicht gehört, bleibt die Urheberschaft beim Upstream. Und Füllmaterial wie Trivia oder Verzeichnis-Einträge bleibt bewusst draußen, um das Signal nicht zu verwässern.

Achtens, wer alles kennt, löst auf nichts auf. Meine Themenliste hatte über 40 mehr oder weniger beliebige Schlagworte. Das habe ich auf eine Handvoll fokussierte Kernthemen zusammengestrichen, möglichst als eindeutige Referenzen statt als nackte Wörter. Der Grund: zu viele Themen verwässern das Signal so sehr, dass man für kein einziges Feld als Autorität erkennbar ist. Die Wette dahinter ist, dass ein scharfes Profil in wenigen Feldern für eine Antwortmaschine wertvoller ist als eine lange, unscharfe Stichwortliste. Der Preis ist Breite bei Nischen-Anfragen. Den zahle ich gerne.

Neuntens, jede Seite soll sagen, was sie ist. Profilseite, Kontaktseite, Artikel, Autorenarchiv: jeder Seitentyp deklariert jetzt seine Rolle und welche Rolle ich dort spiele. Das stärkste Signal „diese Adresse ist das kanonische Profil dieser Person“ entsteht erst dadurch, dass die Profilseite sich auch als Profilseite zu erkennen gibt. Vorher sah sie für eine Maschine aus wie jede beliebige andere Seite und verschenkte diese Aussage komplett. Die Abwägung: mehr Fallunterscheidung im Code, dafür präzise, rollenrichtige Signale.

Wie Antwortmaschinen ihre Quellen wählen

Eine einzelne perfekte Seite reicht nicht. KI-Systeme kreuzprüfen eine Entität über mehrere unabhängige Quellen, bevor sie zitieren. Im schema.org-Vokabular heißt das Stichwort sameAs, frei übersetzt der Verweis auf denselben Ausweis anderswo. Konsistente, echte Verweise erhöhen die Vertrauenswürdigkeit, garantieren aber nichts. Es braucht übereinstimmende Spuren an mehreren Orten. Und Vorsicht vor dem Trugschluss „mehr ist besser“: tote oder inkonsistente Verweise schaden, nur gepflegte, echte Profile zählen.

Der vielleicht wichtigste Befund für alle, die keine Marketing-Abteilung haben: Zitierwürdigkeit ist nicht dasselbe wie Ranking. Ahrefs hat im August 2025 rund 15.000 Long-Tail-Anfragen ausgewertet und KI-Assistenten wie ChatGPT, Gemini und Perplexity dieselben Fragen gestellt. Ergebnis: im Schnitt ranken nur rund 12 Prozent der von diesen Tools zitierten URLs in Googles Top 10, rund 88 Prozent also nicht. Etwa 80 Prozent tauchen für die ursprüngliche Anfrage überhaupt nicht in Googles Ergebnissen auf. Ein Detail der Ehrlichkeit halber: das ist ein Durchschnitt, und Perplexity schert mit knapp 29 Prozent Überschneidung deutlich nach oben aus, hängt also stärker an der klassischen Suche als die anderen. Die Botschaft bleibt trotzdem: Antwortmaschinen wählen nach antwortfertig, glaubwürdig und strukturell sauber, nicht primär nach Suchplatzierung. Genau deshalb kann ein Nischenblog ohne Spitzen-Rankings trotzdem zitierfähig sein. Wer nur in Keyword-Rankings denkt, greift zu kurz.

Und was steigert nun messbar die Sichtbarkeit in generativen Antworten? Eine viel zitierte akademische Arbeit von Forschenden der Princeton University und des IIT Delhi, dazu zwei unabhängige Autoren, hat genau das untersucht, vorgestellt auf der KDD 2024. Sie gilt als die erste Arbeit, die den Begriff Generative Engine Optimization geprägt hat. Die Antwort ist herrlich unspektakulär, und das ist die eigentliche Pointe. Was hilft, ist: wörtliche Zitate einbauen (in der Studie der stärkste Hebel mit rund 41 Prozent mehr Sichtbarkeit), Statistiken nennen (rund 33 Prozent), Quellen angeben (rund 28 Prozent), flüssig und gut lesbar schreiben (ähnliche Größenordnung). Insgesamt bis zu rund 40 Prozent mehr Sichtbarkeit. Zwei Einschränkungen gehören dazu: gemessen wurde nicht Traffic oder Klicks, sondern eine positionsgewichtete Sichtbarkeit innerhalb der KI-Antwort, und die Prozente sind relativ zu einer unoptimierten Ausgangsversion. Das Schlusslicht, mit deutlichem Abstand: klassisches Keyword-Stuffing senkte die Sichtbarkeit sogar, um rund 8 bis 9 Prozent. Die Botschaft ist also kein Geheimtrick, sondern fast schon eine Erlösung: gute, belegte, lesbare Substanz ist die Strategie. Das ist auch der Kern von E-E-A-T, also Erfahrung, Fachkenntnis, Autorität und Vertrauen. Kein Algorithmus-Schalter, sondern ein Signalbündel. Und genau hier zahlt die verifizierte Identität ein: echte Werke, externe Bestätigung und Konsistenz machen Erfahrung und Expertise überhaupt erst maschinell nachvollziehbar.

Ehrlicher Kassensturz

Bleibt die unbequeme Frage: hat das alles etwas gebracht? Fangen wir mit der Korrektur meiner eigenen Anfangs-Wette an, der llms.txt. Die läuft live, der Aufwand für die Datei ist billig und harmlos. Aber sie ist kein bewiesener Hebel. Auf der Search Central Live im Juli 2025 stellte Gary Illyes klar, dass llms.txt keine Google-Initiative ist und Google nicht plant, das Format zu unterstützen. John Mueller hatte sie schon im Frühjahr 2025 mit dem längst ignorierten Keywords-Meta-Tag verglichen, weil sie vom Seitenbetreiber kontrolliert und damit letztlich eine Selbstauskunft ist, die man genauso gut direkt an der Seite überprüfen könnte. Im Dezember 2025 tauchte eine llms.txt kurz in Googles eigener Entwickler-Dokumentation auf und war am selben Tag wieder weg, allem Anschein nach ein automatischer Rollout des Redaktionssystems, keine Kursänderung. Wie es mit der Nutzung auf Anbieterseite wirklich steht, ist unübersichtlich: formell als Standard zugesagt hat es keiner, Google lehnt ausdrücklich ab, OpenAI hat sich nicht festgelegt. Von einzelnen Anbietern heißt es, sie berücksichtigten das Format in ihren Abläufen, aber diese Angaben stammen aus SEO-Quellen, nicht aus offiziellen Hersteller-Mitteilungen. Ich verkaufe das also nicht als Wundermittel. Es schadet nicht, es ist schnell gemacht, aber es ist eher eine Höflichkeitsgeste an Maschinen als ein Garant für irgendetwas.

Anders sieht es bei der strukturierten Identität aus. Hier ist aus „ich habe da mal was erwähnt“ etwas Substantielles geworden. Nicht weil ein Schema magisch wirkt, sondern weil mich der Prozess gezwungen hat, meine eigene Online-Existenz aufzuräumen, Widersprüche zu beseitigen und nur noch Prüfbares zu behaupten. Das wäre auch ohne jede Maschine eine gute Übung gewesen.

Und meine selbstironische Prognose von damals, dass klassische Blogs seltener werden? Die stimmt und stimmt nicht. Dieser Blog schreibt weiter, sehr aktiv sogar. Aber die Verteilung verschiebt sich tatsächlich. Neue Beiträge gehen über ActivityPub ins Fediverse und über eine Brücke nach Bluesky, nicht mehr in erster Linie über die Suchmaschine zum Leser. Insofern stützt die Realität die Prognose, sie widerlegt nur das „Blog ist tot“-Pathos. Es ist kein Sterben, es ist ein Umzug der Verteilwege.

Hat die Maschinenlesbarkeit messbar etwas gebracht? Differenziert betrachtet ja und nein. Die KI-Crawler holen die strukturierten Daten nachweislich ab, das war meine Anfangsprognose und sie hat sich bestätigt. Aber Abruf ist nicht gleich Klick. Die Klick-Konversion aus diesen Kanälen ist niedrig. Das ist kein Widerspruch, das ist genau der Punkt des ganzen Themas, siehe Zero-Click weiter oben. Sichtbar zu sein und besucht zu werden sind zwei verschiedene Dinge geworden.

Damit zum Kerngedanken, der für mich am Ende übrig bleibt: Man kontrolliert nicht, ob eine KI einen zitiert. Man kontrolliert nur, ob man zitierbar ist. Das ist die ganze Aufgabe. Fehlende oder widersprüchliche Daten machen ein Zitat fast unmöglich. Saubere, konsistente, belegbare Daten machen es wahrscheinlicher. Mehr Versprechen gibt es nicht, und jeder, der mehr verspricht, verkauft etwas. SEO ist dabei übrigens nicht tot, das wäre Übertreibung. Technische Hygiene, Crawlbarkeit und gute Inhalte bleiben die Basis. Es verschieben sich nur die Gewichte.

Vor einem halben Jahr habe ich geschrieben, ich sei gespannt, was passiert. Daran hat sich nichts geändert. Ich weiß heute ein paar Dinge genauer, ich habe meine eigene Anfangs-Euphorie an einigen Stellen kassiert, und ich habe vor allem gelernt, dass der ehrlichste Weg auch der robusteste ist. Ob das langfristig der richtige war, weiß ich immer noch nicht. Ich bin weiterhin gespannt.

Siehe auch: Von SEO zu AEO, warum llms.txt, JSON-LD und Answer Engines das Web verändern (der Vorgängerpost mit der ursprünglichen Wette).

Gegenmeinung, eigene Erfahrungen oder ein Befund, der meinem widerspricht? Immer her damit, einfach fragen.

ADS-B-Feeder, Teil 2: der NTP-Bug in fr24feed ist in 1.0.57 gefixt, nur anders als gedacht

Raspberry Pi mit RTL-SDR-Stick und ADS-B-Antenne vor einer Flugradar-Karte. Das Beitragsbild thematisiert die Behebung des NTP-Problems in fr24feed 1.0.57 und die erfolgreiche Wiederanbindung eines Flightradar24-Feeders.

Im ersten Teil dieser kleinen ADS-B-Saga hatte ich am Ende eine Sache offen gelassen und sie sogar fett in die Was-noch-kommt-Liste geschrieben: MLAT aktivieren, sobald Flightradar24 den NTP-Bug fixt. Heute ist es soweit. Der Fix ist da, er kam mit Version 1.0.57, und er kam ganz anders als ich erwartet hätte. Statt den kaputten NTP-Client zu reparieren, hat FR24 ihn einfach rausgeworfen.

Wer den ersten Teil noch nicht kennt, holt das am besten kurz nach: Eigener ADS-B Feeder: Flugzeuge tracken mit Raspberry Pi, RTL-SDR und selbstgebauter Antenne. Dort steht das komplette Setup, die selbstgebaute Antenne und eben die Geschichte mit dem NTP-Bug, der meinen Feeder über Wochen am Online-Gehen gehindert hat. Den Bug selbst erkläre ich hier nur noch in ein paar Sätzen, die lange Version steht drüben.

Worum es ging, ganz kurz

Seit Version 1.0.55 hatte der fr24feed-Daemon einen internen NTP-Client, der schlicht nichts tat. Kein einziges Paket auf Port 123, also keine Zeitsynchronisation, und ohne synchronisierte Zeit lässt FR24 den Feeder nicht online gehen. Man hängt in einer Endlosschleife aus Failed to synchronize fest und kommt nie über dieses Sync-Gate hinaus. Mein Workaround war die letzte funktionierende Version 1.0.54 mit apt-mark hold festzunageln und auf einen Fix zu warten.

Im März hatte ich FR24 einen Bug-Report mit strace- und tcpdump-Belegen geschickt. Die Antwort von Muazzam aus dem Support: auf ihrer Seite nicht reproduzierbar, Verdacht auf eine Regression durchs Build-System und nicht durch eine Änderung am NTP-Client selbst. Ich blieb hartnäckig, lieferte am 6. Juni eine syscall-genaue A/B-Analyse nach, und am 8. Juni kam die erlösende Mail (Ticket #741092): „should be fixed in v 57 which will be released later today“. War es dann auch, noch am selben Tag lag 1.0.57-1 im Repo.

Warum ich nicht einfach apt upgrade tippe

fr24feed ist closed-source, proprietär, kein GitHub, keine Quellen. Ich kann ein Release also nicht am Code beurteilen, sondern nur an seinem Verhalten. Und ein blindes Upgrade auf dem laufenden Produktiv-Feeder kam nicht in Frage. Wenn 1.0.57 genauso kaputt gewesen wäre wie 1.0.56, hätte ich mir den Feeder zerschossen und müsste erst wieder zurückrollen, bevor überhaupt wieder Daten fliessen.

Die saubere Variante: das Binary aus dem .deb extrahieren und als isolierte Wegwerf-Instanz gegen eine Wegwerf-Config unter strace laufen lassen. Eigener Fake-Key, ein toter Receiver-Port, der echte Feeder läuft dabei unberührt weiter. Erst wenn der Testlauf sauber durchkommt, fasse ich die Produktion an.

Der Testaufbau, eine Wegwerf-Instanz unter strace

Die Test-Config ist bewusst minimal gehalten. Sie muss nur weit genug kommen, dass der Feeder die Zeitsynchronisation versucht, alles danach interessiert für diesen Test nicht:

fr24key=0123456789abcdef
receiver=beast-tcp
host=127.0.0.1:39999    # absichtlich toter Port, fuer die NTP-Phase egal
bs=no
raw=no
mlat=no
logmode=0

Dann sehen, ob der Pi die neue Version überhaupt schon sieht, und das Paket herunterladen ohne es zu installieren:

apt-cache policy fr24feed
#   Installed: 1.0.54-0
#   Candidate: 1.0.57-1
#      1.0.57-1 500 https://repo-feed.flightradar24.com flightradar24/raspberrypi-stable arm64

apt-get download fr24feed
dpkg-deb -x fr24feed_1.0.57-1_arm64.deb extract57

Erst die Toolchain vergleichen

Bevor ich überhaupt gestartet habe, ein kurzer Blick in die .comment-Section der ELF-Binaries. Die verrät, mit welchem Compiler gebaut wurde, und genau das war FR24s Verdacht:

readelf -p .comment extract57/usr/bin/fr24feed | grep -i gcc
#   GCC: (Debian 14.2.0-19) 14.2.0                       1.0.57 (und 1.0.56)
readelf -p .comment /usr/bin/fr24feed | grep -i gcc
#   GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0           1.0.54 (funktioniert)

Das ist der interessante Punkt: 1.0.57 ist mit derselben GCC-14-Toolchain gebaut wie das kaputte 1.0.56. „Neu kompiliert“ allein ist also noch kein Fix, sonst wäre 1.0.56 ja schon heil gewesen. Genau das machte den strace-Test erst spannend, denn ich konnte nicht aus der Versionsnummer ableiten, ob sich am Verhalten wirklich etwas geändert hat. Der Sprung von GCC 11 auf 14 plus der Distro-Wechsel von Ubuntu 22.04 auf Debian ist gross. GCC 14 ist deutlich strenger bei Undefined Behaviour und uninitialisierten Daten, und ein latenter Bug im NTP-Transmit-Pfad konnte unter GCC 11 unsichtbar bleiben und unter GCC 14 dann brechen. FR24s Build-System-Theorie war im Nachhinein also gar nicht so abwegig.

Der A/B-Lauf

Beide Versionen, die neue 1.0.57 und die installierte 1.0.54 als Kontrolle, laufen durch denselben Harness, auf derselben Maschine, am selben Tag. Ich tracke nur die Netzwerk-Syscalls, das reicht um zu sehen ob da etwas auf Port 123 geht:

timeout -s INT 125 strace -f -tt -e trace=%network -yy -o v57_today.strace extract57/usr/bin/fr24feed --config-file=test.ini > v57_today.log 2>&1

Das Ergebnis, und es überrascht

Mein Abnahmekriterium war simpel formuliert: sendto auf Port 123 muss wieder feuern, dann ist der NTP-Client repariert. Das Ergebnis war eine kalte Dusche und gleichzeitig die ganze Pointe dieser Geschichte:

1.0.54 (Kontrolle)1.0.56 (kaputt)1.0.57-1 (neu)
NTP sendto auf Port 1233x (eigener Client)0x0x, Client entfernt
Source-Address-Discoveryjajaja (Rest-Code)
Zeitsync-Logoffset +0.001 sFailed to synchronizeconfirmed with timesyncd
Failed-to-synchronize-Loopneinja, endlosnein
Kommt über das Sync-Gate?janeinja
ToolchainGCC 11.4.0GCC 14.2.0GCC 14.2.0

Über den gesamten 125-Sekunden-Lauf von 1.0.57 hinweg gab es kein einziges Paket auf Port 123. Null. Genau wie beim kaputten 1.0.56. Nach meinem ursprünglichen Kriterium hätte ich das Release durchfallen lassen müssen. Und trotzdem war der Bug weg. Der entscheidende Hinweis steht eine Zeile vorher im Log:

[time][i]Time synchronization confirmed with timesyncd
[feed][i]Downloading configuration
[main][i]Feed Network client started
[feed][d]Fetching configuration
[feed][e]Result: failure, message: Not found, check your key!

Der einzige Fehler im ganzen Testlauf ist „check your key!“, und der ist erwartet, weil meine Test-Config absichtlich den Fake-Key 0123… benutzt. Das heisst: der Feeder läuft komplett durch bis zur Feed-Registrierung. Genau vor diesem Punkt hingen 1.0.55 und 1.0.56 endlos in ihrer Sync-Schleife fest. Bug also weg, nur eben nicht so, wie ich gedacht hatte.

Zum Vergleich der Beweis aus dem 1.0.54-Kontrolllauf, wo der eigene NTP-Client noch feuert. Hier sieht man das sendto auf Port 123 schwarz auf weiss:

sendto(5<UDP:[25798]>, "33...", 48, 0,
       {sa_family=AF_INET, sin_port=htons(123),
        sin_addr=inet_addr("85.10.204.50")}, 16) = 48
[time][i]Time synchronized correctly, offset +0.001 seconds

Pragmatischer Workaround statt echtem Fix

Was FR24 gemacht hat, ist kein Reparieren des NTP-Clients, sondern ein Umgehen des Problems auf Architektur-Ebene. Der kaputte interne Client ist raus, übrig geblieben ist nur noch etwas Rest-Code für die Source-Address-Discovery. Die eigentliche Zeitsynchronisation delegiert der Feeder jetzt an systemd-timesyncd, also an den NTP-Dienst des Betriebssystems. Statt selbst Pakete auf Port 123 zu schicken, fragt er das OS einfach: ist deine Zeit synchron? Und wenn ja, geht es weiter.

Ehrlich gesagt finde ich das eine vernünftige Entscheidung. Ein eigener NTP-Client in einer Feeder-Software war ohnehin Reinventing the Wheel, das Betriebssystem kann das besser und macht es sowieso schon. Dass der eigentliche Bug damit nie wirklich gefunden wurde, ist aus Ingenieurssicht ein kleiner Wermutstropfen, aber für den Anwender zählt nur, dass der Feeder läuft. Und das tut er.

Das Upgrade mit Sicherheitsnetz

Erst nachdem der Testlauf sauber durch war, ging es an die Produktion. Vorher noch das alte Paket und die Config wegsichern, damit ein Rollback jederzeit ein Einzeiler bleibt:

cp /var/cache/apt/archives/fr24feed_1.0.54-0_arm64.deb /tmp/fr24test/rollback/
sudo cp /etc/fr24feed.ini /etc/fr24feed.ini.bak-20260608-161113

sudo apt-mark unhold fr24feed
sudo apt-get install -y --only-upgrade fr24feed   # 1.0.54-0 auf 1.0.57-1

# Stolperstein: das Paket STOPPT den Dienst beim Upgrade, startet ihn aber nicht neu
sudo systemctl start fr24feed

# Wieder pinnen, jetzt auf die verifiziert gute Version
sudo apt-mark hold fr24feed

Der Stolperstein mit dem nicht neu gestarteten Dienst ist eine Kleinigkeit, kostet aber Nerven wenn man es nicht weiss und sich wundert warum der Feeder nach dem Upgrade tot ist. Ein systemctl start später lief alles. Die Verifikation kam aus der monitor.json und dem Journal:

"build_version":"1.0.57-1"
"feed_status":"connected"
"feed_num_ac_tracked":"92"

[time][i]Time synchronization confirmed with timesyncd
[reader][i]Timestamp source changed from UNKNOWN to SYSTEM-VALIDATED
[feed][n]connected via UDP (fd 6)
[feed][n]working
[feed][i]sent 46,0 AC

feed_status: connected und 92 getrackte Flugzeuge. Nach Wochen auf der festgenagelten 1.0.54 ist der Feeder endlich wieder auf einer aktuellen Version und kommt sauber über das Sync-Gate. Genau das wollte ich.

Die Kehrseite, eine neue Abhängigkeit

Wer einen eigenen Feeder betreibt, sollte das hier auf dem Schirm haben: 1.0.57 spricht selbst kein NTP mehr, also braucht es jetzt einen laufenden NTP-Dienst im Betriebssystem. Auf dem Standard-Pi24-Image ist das systemd-timesyncd, und damit funktioniert es out of the box. Kurz prüfen schadet trotzdem nicht:

systemctl is-active systemd-timesyncd     # active
timedatectl show -p NTPSynchronized       # NTPSynchronized=yes

Wer timesyncd oder chrony bewusst deaktiviert hat, oder ein abgespecktes Image ganz ohne NTP-Daemon fährt, könnte mit 1.0.57 jetzt ein neues Sync-Problem bekommen. Das ist der Preis des pragmatischen Fixes: FR24 hat die Verantwortung fürs Zeit-Setzen ans OS abgegeben, und damit muss das OS sie auch wahrnehmen.

Bonus-Fund: 1.0.57 bringt native GPS-Unterstützung

Beim Stöbern im neuen Binary ist mir noch etwas aufgefallen, das für die MLAT-Frage aus Teil 1 hochinteressant ist: 1.0.57 bringt einen PositioningNmeaDecoder und eine ganze Reihe neuer gps--Direktiven mit. Das könnte heissen, dass sich der VK-162 endlich für das MLAT-Timing nutzen lässt, das ja bislang auf NOT-PERMITTED stand.

strings /usr/bin/fr24feed | grep -oE 'gps-[a-z-]+' | sort -u
#   gps-altitude gps-antenna-connected gps-base-timestamp gps-ip gps-latitude
#   gps-longitude gps-mode gps-status gps-time ...

# Welcher gps-mode-Wert ist gueltig? Durchprobiert:
#   gps-mode=serial  -> [e]Unsupported gps-mode=serial!
#   gps-mode=nmea    -> akzeptiert (einziger gueltiger Wert)

So weit, so vielversprechend. Mit gps-mode=nmea plus mlat-without-gps=no öffnet das Binary dann aber /dev/ttyACM0 nicht selbst, sondern loggt nur stoisch:

[main][i]Waiting for GPS time

An der Hardware liegt es nicht, die liefert nachweislich einen sauberen Fix mit 9 Satelliten, parallel mitgelesen:

$GPGGA,161727.00,5034.69002,N,00656.93035,E,1,09,0.86,384.0,M,...   # Fix, 9 Sat, 384 m

Meine erste Vermutung war, dass 1.0.57 die NMEA-Daten gepusht erwartet, also über die Beast- und Decoder-Strecke oder über eine Netzwerkquelle per gps-ip statt über ein direktes Serial-Open des Dongles. Statt auf der Produktion herumzuraten habe ich FR24 aber lieber direkt gefragt, welche fr24feed.ini-Schlüssel zu einem seriell angeschlossenen NMEA-GPS gehören, Device-Pfad, Baudrate und so weiter.

Update vom 9. Juni 2026: Die Antwort von Muazzam aus dem Support (weiterhin Ticket #741092) kam am nächsten Tag und war kurz, aber unmissverständlich:

No, a local gps won’t help with mlat. For good mlat you need nano second timestamps that fpga provides. Also, we dont have an support for it.

Damit ist die Frage abschliessend beantwortet, wenn auch anders als erhofft. Ein lokal angeschlossener Serial- oder NMEA-GPS ist für MLAT schlicht keine gültige Timing-Quelle, und fr24feed unterstützt diesen Fall auch gar nicht. Der Grund steckt in der Physik der Multilateration: MLAT rechnet Flugzeugpositionen aus den Laufzeitunterschieden desselben Signals an mehreren Empfängern aus. Damit das aufgeht, müssen die Empfänger ihre Empfangszeitpunkte im Nanosekunden-Bereich stempeln, und solche Zeitstempel liefert nur dedizierte FPGA-Hardware der Radarcape-Klasse. Ein NMEA-GPS über USB-Serial hat dagegen Jitter im Millisekunden-Bereich, aus der USB-Latenz und dem Timing der NMEA-Sätze. Das sind gut sechs Grössenordnungen daneben, und selbst mit einem sauberen PPS-Signal kommt man an die FPGA-Genauigkeit nicht heran.

Das ordnet auch mein gps-mode=nmea-Experiment von oben sauber ein. Die GPS-Direktiven in 1.0.57 dienen faktisch nur der Positionsangabe, nicht dem MLAT-Timing. Das beobachtete [main][i]Waiting for GPS time, ohne dass der Feeder /dev/ttyACM0 überhaupt öffnet, war also kein Konfigurationsfehler meinerseits, sondern schlicht fehlender Support für genau diesen Anwendungsfall.

Für mich heisst das, der GPS-Dongle der seit März für genau diesen Moment bereitliegt, bleibt vorerst in der Schublade. Etwas schade, aber die Begründung ist nachvollziehbar und technisch sauber. Und für alle mit dem gleichen Setup ist die Lehre eindeutig: mit einem reinen RTL-SDR plus USB-GPS lässt sich MLAT bei FR24 nicht aktivieren, egal welche fr24feed.ini-Verdrahtung man probiert. MLAT bleibt dauerhaft auf NOT-PERMITTED. Wer MLAT wirklich will, kommt um Timing-Hardware mit FPGA nicht herum.

Fazit, und die eigentliche Lehre

Die schönste Lektion steckt nicht in der Versionsnummer, sondern in meinem Abnahmekriterium. Ich war so auf den einen Syscall fixiert, dass ich beinahe das richtige Ergebnis als Fehlschlag abgehakt hätte. sendto auf Port 123 war nie das eigentliche Ziel, das war nur die zufällige Art, wie 1.0.54 die Zeit synchronisiert hat. Das richtige Erfolgskriterium war die ganze Zeit ein anderes: kommt der Feeder über das Sync-Gate, ja oder nein. Ein bestimmter Syscall ist Mittel zum Zweck, nicht der Zweck selbst. Wer Verhalten testet statt Implementierung, läuft seltener in so eine Falle.

FR24 bekommt von mir Lob für die schnelle Reaktion am Ende und einen pragmatischen Fix, der das Problem zuverlässig erledigt. Ein kleiner Kritikpunkt bleibt, dass der eigentliche Bug nie gefunden wurde, sondern nur umgangen. Aber Hand aufs Herz: ein funktionierender Feeder ist mir lieber als ein vollständig aufgeklärter, der nicht läuft. Mein Beitrag war am Ende vor allem die Reproduktion auf genau der arm64-Hardware, die FR24 im März nicht zum Fehler bringen konnte. Dass der Fix jetzt auf eben dieser Maschine hält, habe ich dem Support noch einmal zurückgemeldet, damit sie die Regression sauber abschliessen können. Manchmal ist der wertvollste Teil eines Bug-Reports, dass man hartnäckig bleibt und sauber misst.

Siehe auch:

Betreibt ihr selbst einen FR24-Feeder und seid über den NTP-Bug gestolpert, oder lasst ihr MLAT über dedizierte Timing-Hardware mit FPGA laufen? Dann lasst es mich gerne wissen, ihr dürft mich jederzeit fragen.

« Ältere Beiträge

© 2026 -=Kernel-Error=-RSS

Theme von Anders NorénHoch ↑