Zwei Keyoxide-Beiträge liegen schon hier, im ersten vier Claims über DNS, GitHub und Matrix, im zweiten die Linux-App und der Matrix-Kanal des Projekts. Was in beiden fehlte, war ein fünfter Claim, der genau dieses Blog beweist, den Fediverse-Actor @kernel-error.de@www.kernel-error.de. Der ist keine Mastodon-Instanz, sondern das WordPress-ActivityPub-Plugin, das aus diesem Blog selbst einen Actor macht. Im August habe ich genau diesen Claim gebaut, ausgerollt und noch am selben Tag wieder zurückgebaut, weil er einfach nicht grün wurde. Das hier ist die Geschichte, warum nicht, und was seit heute anders ist.
Der gescheiterte erste Versuch im August
Der Claim in der Selbstsignatur des Schlüssels: proof@ariadne.id=https://www.kernel-error.de/@kernel-error.de. Der Beweis dazu: ein Extra Field des ActivityPub-Plugins namens OpenPGP mit dem Wert openpgp4fpr:45FC…6DEB, exakt die Platzierung, die Keyoxide selbst als unterstützt dokumentiert. Der Actor lieferte auch wortgetreu {"type":"PropertyValue","name":"OpenPGP","value":"openpgp4fpr:45FC…6DEB"} aus. Trotzdem blieb der Claim rot.
Bei der Fehlersuche im August ist mir dabei ein echter Bug auf der eigenen Seite untergekommen und gleich mitgefixt: WordPress beantwortete OPTIONS-Anfragen auf die Actor-URL mit einem 301 auf die Startseite, ein umgeleiteter CORS-Preflight scheitert damit in jedem Browser hart. Eine eigene nginx-Location, die den Preflight selbst mit 204 beantwortet, hat das behoben, dauerhaft, unabhängig vom Rest dieser Geschichte, weil es jeden browserseitigen Consumer des Actors betrifft. Das steht bereits ausführlich im ersten Keyoxide-Beitrag.
Was im August systematisch ausgeschlossen wurde
Nach dem CORS-Fix ließ sich der Actor sauber abrufen, im Access-Log stand GET /@kernel-error.de 200 … "doipjs/2.1.0", das Werkzeug hinter Keyoxide hatte den Actor also tatsächlich in der Hand. Der Rest wurde einzeln durchgestrichen. Provider-Matching: die ActivityPub-Regel von doipjs ist ein Catch-all für jede https-URL, NodeInfo-Anfragen im Log belegten, dass die ActivityPub-Nachbearbeitung wirklich lief. Feld-Pfad: attachment.value ist einer von drei Pfaden, die doipjs durchsucht, neben summary und content. Groß- und Kleinschreibung des Fingerprints: doipjs kleinschreibt vor dem Vergleich, also irrelevant. Am Ende blieb nur ein Rückbau übrig, Notation raus, Redeploy über alle sieben Schlüssel-Kanäle, Feld gelöscht. Alles sah richtig aus, und trotzdem war es rot. Genau dieser Widerspruch ist der Grund, warum ich das jetzt, im September, zu Ende verfolgt habe.
Die Ursache im Verifizierer: doipjs und runJSON
doipjs ist die Bibliothek hinter keyoxide.org, Quelltext offen auf Codeberg. Die eigentliche Suchfunktion heißt runJSON(proofData, checkPath, params) und läuft einen JSON-Pfad ab. Zwei Zeilen daraus erklären den ganzen Fehler:
// Array: der Reihe nach abarbeiten, kein try/catch pro Element
if (Array.isArray(proofData)) {
let result = false
for (let index = 0; index < proofData.length; index++) {
if (result) { continue }
result = await runJSON(proofData[index], checkPath, params)
}
return result
}
// Objekt ohne den naechsten Schluessel im Pfad: wirft, sofort
if (typeof proofData === 'object' && !(checkPath[0] in proofData)) {
throw new Error('err_json_structure_incorrect')
}
Steckt der Beweis in einem Array, iteriert runJSON über die Einträge und ruft sich selbst für jedes Element auf, ohne dabei einzelne Fehler abzufangen. Fehlt einem Objekt der nächste erwartete Schlüssel im Pfad, wirft die Funktion sofort. Beides zusammen bedeutet: im ersten Element eines Arrays, dem der gesuchte Schlüssel fehlt, bricht die ganze Suche ab, alles danach wird nie mehr angeschaut. Aufgefangen wird das erst eine Ebene höher, in run(), und zwar pro Zielpfad einzeln, summary, attachment.value, content bekommen je einen eigenen Try-Block. Ein Wurf in attachment.value gibt also nur diesen einen Pfad auf, summary und content laufen trotzdem weiter, finden dort aber naturgemäß nichts, weil der Beweis nur im Attachment-Array steht.
Die andere Hälfte: wie das ActivityPub-Plugin Profilfelder ausliefert
Die zweite Hälfte des Bugs steckt im WordPress-Plugin selbst, in class-extra-fields.php, Funktion fields_to_attachments(). Für jedes einzelne Profilfeld erzeugt die Funktion nicht ein Attachment, sondern zwei: zuerst ein klassisches PropertyValue mit name und value, das ist die Tabelle, die Mastodon rendert. Danach, als Unterstützung für FEP-fb2a, entweder ein Link mit href, wenn der Wert ein einzelner Link ist, oder ein Note mit content, wenn nicht.
Weder Link noch Note hat einen Schlüssel namens value. Im echten Actor sah das im August deshalb so aus:
attachment[0] PropertyValue Blog value=... geprueft, kein Treffer attachment[1] Link Blog href=... kein value-Schluessel, wirft attachment[2+] ... nie erreicht
doipjs prüft auf einem WordPress-Actor also faktisch nur attachment[0]. Die bestehenden Felder standen im August in der Reihenfolge Blog, GitHub, Matrix, Kontakt, das OpenPGP-Feld kam als fünftes dazu und lag damit nie an erster Stelle. Genau das erklärt jede einzelne Beobachtung von damals: erfolgreich abgerufen, korrektes JSON im Feld selbst, trotzdem rot.
Warum das auf Mastodon nie auffällt
Mastodon liefert für seine eigenen Profilfelder ausschließlich PropertyValue-Einträge aus, jedes Element im Array trägt also value, und die Schleife läuft ungestört durch, egal an welcher Position der Beweis steht. Der FEP-fb2a-Zusatz ist eine Erweiterung für genau die Fälle, die Mastodon nicht abdeckt, in diesem Fall anklickbare Links im Profil außerhalb der reinen Tabellenoptik. Niemand hat hier etwas falsch gemacht: das Plugin erweitert sinnvoll über Mastodon hinaus, doipjs ist gegen Mastodons Datenform geschrieben, und beide Annahmen treffen für sich genommen zu. Nur zusammen ergeben sie eine Reihenfolge-Abhängigkeit, die auf keinem der beiden Wege sichtbar wird, wenn man nur die eigene Seite testet.
Beim Gegenlesen dieser Analyse kamen noch zwei Präzisierungen dazu, die den Befund nicht ändern, aber schärfen. Erstens: ein Wurf in attachment.value beendet nicht die gesamte Verifikation, nur diesen einen Pfad, summary und content laufen wie oben beschrieben trotzdem weiter. Zweitens: ein reiner Unit-Test von verifications.run() übergeht den eigentlichen Netzwerk-Abruf, der wirkliche Beweis ist ein kompletter Durchlauf über Claim.match() und verify() gegen den lebenden Actor, dazu unten mehr. Vorschlag aus derselben Runde war außerdem, den Beweis zusätzlich oder stattdessen im Profil-summary zu platzieren, das ist positionsunabhängig. Entscheidung: das Extra Field bleibt, weil es das sauberere Profil ergibt, die Reihenfolge-Abhängigkeit wird dafür hier dokumentiert, summary bleibt die bekannte Rückfalloption.
Reproduziert, bevor irgendetwas am Schlüssel geändert wurde
Bevor der Schlüssel angefasst wurde, habe ich doipjs 2.1.0 lokal aus npm in ein Wegwerf-Verzeichnis installiert und dessen eigene verifications.run() plus die ActivityPub-Provider-Definition direkt gegen das echte, live abgerufene Actor-JSON laufen lassen, mit dem Beweis an unterschiedlichen Positionen eingesetzt:
live actor, unveraendert result=false errors=["err_json_structure_incorrect","err_json_structure_incorrect"] OpenPGP an erster Stelle (Index 0) result=true errors=[] OpenPGP an letzter Stelle (Index 8), wie im August result=false errors=["err_json_structure_incorrect","err_json_structure_incorrect"] Nur PropertyValue (Mastodon-Form), OpenPGP letzte result=true errors=[]
Die beiden Fehler in den ersten zwei Zeilen sind der attachment.value-Pfad, dort steht der Link- oder Note-Eintrag ohne value, und der content-Pfad, ein Person-Actor hat kein Top-Level-content-Feld. Die letzte Zeile ist die Kontrollprobe: derselbe Beweis an derselben Position funktioniert, sobald das Array die reine Mastodon-Form hat. Vier Zeilen, die die ganze Geschichte erzählen. Der relative Importpfad war dabei nötig, doipjs/src/… lässt sich nicht als Package-Subpath importieren, die exports-Angabe in dessen package.json blockt das.
Der Fix auf dem Blog
Das Extra Field OpenPGP wurde neu angelegt, mit menu_order = -1, während die bestehenden vier auf 0 bis 3 stehen, Inhalt im selben Absatz-Blockformat, das das Plugin auch für die anderen Felder benutzt, <!-- wp:paragraph --><p>openpgp4fpr:45FC…6DEB</p><!-- /wp:paragraph -->. Eingefügt wurde das per direktem $wpdb->insert(), nicht über wp_insert_post(), weil Letzteres die ActivityPub-Hooks auslöst und ein Update für den Actor in die Outbox stellt, das später als Geister-Post auftaucht, Blog-Titel, Datum 2003. Gemessen: ap_outbox stand vorher und nachher bei genau demselben Wert. Danach zuerst der Redis-Object-Cache geleert, dann der nginx-FastCGI-Cache, die umgekehrte Reihenfolge würde FastCGI aus noch altem Redis-Inhalt neu befüllen. Im lebenden Actor sieht das Ergebnis jetzt so aus:
0 {"type": "PropertyValue", "name": "OpenPGP", "value": "openpgp4fpr:45FCD081ADB54872EA5B06B9893DE0CDDE986DEB"}
1 {"type": "Note", "name": "OpenPGP", "content": "openpgp4fpr:45FCD081ADB54872EA5B06B9893DE0CDDE986DEB"}
2 {"type": "PropertyValue", "name": "Blog", "value": "<a href=...>"}
3 {"type": "Link", "name": "Blog", "href": "https://www.kernel-error.de", ...}
Der reine Text bekommt hier eine Note als Begleitung, kein Link drin, also kein Link-Eintrag. attachment[0] trägt jetzt den Beweis, genau da, wo doipjs zuerst und im Zweifel einzig hinsieht.
Die Gegenprobe vor der Schlüsseländerung
Nur der Reihenfolge-Test allein hätte mir nicht gereicht. Vor der eigentlichen Schlüsseländerung lief deshalb ein kompletter doipjs-Durchlauf, Abruf, Provider-Matching, Verifikation, Nachbearbeitung, gegen die produktive Seite, mit dem Fingerprint direkt übergeben, den Beweis brauchte der Schlüssel dafür noch nicht:
const claim = new Claim('https://www.kernel-error.de/@kernel-error.de', FINGERPRINT)
claim.match() // ergibt: activitypub, owncast (die URI ist absichtlich mehrdeutig)
await claim.verify()
// ergibt: status 200 (VERIFIED), provider "wordpress" (aus NodeInfo),
// profile "@kernel-error.de@www.kernel-error.de"
Erst nachdem dieser Test grün war, bekam der Schlüssel die Notation, dieselbe Regel wie schon bei den DNS-Claims: der Schlüssel behauptet nie öffentlich etwas, das noch nicht beweisbar ist.
Schlüssel und Redeploy über sieben Kanäle
Die Notation kam ausschließlich in die Namens-UID, in einer skriptgesteuerten gpg --batch --command-fd 0 --edit-key-Sitzung, sonst bekommt auch die Foto-UID den Claim mit. Danach geprüft: fünf Claims auf UID 1, Algorithmus-Präferenzen und Feature-Flags unverändert, die Governikus-eID-Zertifizierung und die Gegensignatur des alten Schlüssels weiterhin gültig, Ablaufdaten unverändert, Foto-UID unangetastet. Jede Claim-Änderung erzeugt eine neue Selbstsignatur, deshalb musste der aktualisierte Schlüssel auf alle sieben Kanäle: keys.openpgp.org, keyserver.ubuntu.com, pgpkeys.eu, WKD, sowohl über die Advanced-Methode via keys.openpgp.org als auch direkt selbst gehostet, der Download auf dem Blog, und der DANE-OPENPGPKEY-Record per Zone-Freeze/Thaw, DNSSEC-ad-Flag danach an drei öffentlichen Resolvern geprüft.
Eine gute Frage, die während des Rollouts aufkam: wenn WKD auf der eigenen Seite doch reicht, warum überhaupt noch auf die Keyserver hochladen, während gerade erst getestet wurde? Zwei Antworten. Erstens war der Test zu diesem Zeitpunkt schon abgeschlossen, der Ende-zu-Ende-Check lief gegen den lebenden Actor mit dem blanken Fingerprint, bevor der Schlüssel den Claim überhaupt trug, veröffentlicht wurde der Schlüssel erst, als das Ergebnis grün war. Zweitens ist WKD hier gar nicht wirklich nur die eigene Seite: die WKD-Advanced-Methode unter openpgpkey.kernel-error.com ist ein CNAME auf den WKD-Dienst von keys.openpgp.org, und Clients probieren Advanced zuerst. Auch das Keyoxide-Profil per Fingerprint holt sich den Schlüssel von keys.openpgp.org. Ohne das Keyserver-Update hätten die meisten Prüfer also weiterhin den alten Schlüssel mit vier Claims gesehen. Wahr und ebenfalls wichtig: Keyserver behalten jede Generation einer Selbstsignatur, es zählt nur die jüngste. Ein Claim lässt sich mit einer neueren Selbstsignatur zurückziehen, die alte Version verschwindet aber nie, das gehört vor der Veröffentlichung mitentschieden, nicht danach.
Das Ergebnis
Der Schlüssel abgerufen wie ein Fremder ihn abrufen würde, per HKP von keys.openpgp.org und per WKD, und mit doipjs gegen alle fünf Claims geprüft:
OK 200 wordpress @kernel-error.de@www.kernel-error.de OK 200 github Kernel-Error OK 200 dns kernel-error.de OK 200 dns kernel-error.com (Matrix braucht auf der Pruefer-Seite einen echten Matrix-Account, auf keyoxide.org gruen, in einem blanken lokalen Test nicht pruefbar)
Der Provider steht dabei als wordpress, doipjs liest den Software-Namen aus NodeInfo aus, bei diesem Blog tatsächlich {"software":{"name":"wordpress","version":"7.1"}}, live nachgeprüft. Die naheliegende erste Reaktion darauf war „müsste da nicht Mastodon stehen?“, die Antwort gehört in diesen Beitrag: der Account ist ein Fediverse-Account, kein Mastodon-Account, Mastodon ist nur eine von mehreren Implementierungen. Ein Account auf mastodon.social würde [mastodon] zeigen, einer auf einer GoToSocial-Instanz [gotosocial]. Man könnte den Namen in NodeInfo fälschen, das wäre aber falsch und nicht nur stilistisch: Server und Clients passen ihr Verhalten am Software-Namen aus, sie würden dann Mastodon-spezifische Endpunkte abfragen, die hier gar nicht existieren, und es wäre eine unwahre Aussage in einem Profil, dessen ganzer Zweck überprüfbare Echtheit ist.
Frische Schlüsselbunde über gpg --auto-key-locate clear,<methode> --locate-external-keys liefern für WKD, DANE und Keyserver jeweils alle fünf Claims. Größen mit fünf Claims: WKD direkt 13178 Byte, der volle Download 17926 Byte, der DANE-Record 1368 Byte, davon 70 Byte allein für diesen neuen Claim. Im Actor liegt attachment[0] jetzt auf PropertyValue OpenPGP, attachment[1] auf dessen Note-Begleitung, der Wert steht als reiner Text ohne umschließendes <p>, weil ein vorhandenes Code-Snippet auf dem Blog genau das aus Extra-Field-Werten entfernt, ursprünglich damit Mastodons rel="me"-Linkprüfung funktioniert, die einen nackten <a> als Wurzelelement erwartet. Zwei verschiedene Prüfer, zwei verschiedene Annahmen über die Form derselben Daten, an dieser einen Stelle trifft es genau zusammen.
Auf Mastodon selbst zeigt sich das neue Profilfeld erst, wenn entfernte Instanzen den Actor erneut abrufen, absichtlich wurde keine Update-Aktivität verschickt, das wäre wieder der Geister-Post-Mechanismus von oben. Keyoxide betrifft das nicht, das Werkzeug holt den Actor bei jeder Prüfung frisch.
Lektionen
- „Bei mir ist alles richtig“ kann wahr sein und trotzdem scheitern. Entscheidend sind die Annahmen des Verifizierers über die Form der Daten, nicht nur der Inhalt. Den Quelltext des Prüfers lesen, bevor man die eigene Seite zum fünften Mal durchsucht.
- Eine Plugin-Funktion für bessere Zukunftskompatibilität, hier FEP-fb2a, bricht einen Verbraucher, der gegen Mastodons Datenform geschrieben wurde. Für sich genommen ist keiner der beiden im Unrecht.
- Eine versteckte Abhängigkeit blieb übrig: der Beweis funktioniert nur, solange das OpenPGP-Feld das erste Profilfeld ist. Wer eigene Extra Fields verwaltet, sollte das im Kopf behalten.
Ein Upstream-Report an doipjs auf Codeberg ist noch nicht eingereicht. Der eigentlich richtige Fix wäre ein Try/Catch pro Array-Element in runJSON, das würde nebenbei auch die Abhängigkeit von der Feldreihenfolge auflösen.
Siehe auch
- Keyoxide: den OpenPGP-Schlüssel an Online-Identitäten binden, die ersten vier Claims und der CORS-Fix, der diesen Beitrag erst möglich gemacht hat.
- Keyoxide: die Linux-App ist im Flathub, dazu ein Faktencheck des Profile-ID-QR-Codes.
- Einen modernen OpenPGP-Schlüssel bauen, das Rezept für genau den Schlüssel, der hier fünf Claims trägt.
Selbst schon mal an einer Reihenfolge-Abhängigkeit zwischen zwei fremden Projekten verzweifelt, oder eigene Erfahrungen mit ActivityPub-Profilfeldern? Dann darfst du mich sehr gerne fragen.

![Keyoxide-Profil mit fünf grünen Identity Claims, darunter der Fediverse-Actor @kernel-error.de@www.kernel-error.de mit dem Vermerk [wordpress].](https://www.kernel-error.de/wp-content/uploads/2026/09/keyoxide-five-claims-green-1024x604.png)

Schreibe einen Kommentar