Matrix betreiben: Wenn die E2EE-Verschlüsselung zum Betriebsproblem wird

Ein Synapse-Homeserver im Lab, der schmerzhafte Sonderfall Ende-zu-Ende-Verschlüsselung für meinen Bot ZeroClaw — und das Tooling, das ich bauen musste, um ihn beherrschbar zu machen
Table of contents

Ich betreibe einen eigenen Matrix -Homeserver — Synapse , erreichbar unter matrix.this-is-fine.social. Der laufende Betrieb ist erfreulich langweilig; ein Aspekt aber hat mich mehr Nerven gekostet als alles andere zusammen: die Ende-zu-Ende-Verschlüsselung, und zwar ausgerechnet für meinen automatisierten Teilnehmer, den Bot ZeroClaw. Dieser Beitrag handelt von diesem Schmerz — und dem Tooling, das ich bauen musste, um ihn zu zähmen.

Der Homeserver

Der unkomplizierte Teil zuerst. Synapse läuft als Helm-Release auf hydra, mit einer CloudNativePG-Datenbank (synapse-v1) als Backend. Nach außen teilt er sich den Host mit meiner Fediverse-Präsenz: Dieselbe Domain bedient über das Envoy-Gateway sowohl snac als auch Synapse — die /_matrix/- und /.well-known/matrix/-Pfade werden an den Homeserver geroutet, der Rest geht ans Fediverse.

serverName: "${SOCIAL_DOMAIN}"
public_baseurl: "https://matrix.${SOCIAL_DOMAIN}/"
externalPostgresql:
  host: synapse-v1-rw
  database: synapse

Das alles tut, was es soll, und meldet sich selten zu Wort. Interessant wird es erst bei der Verschlüsselung.

Warum E2EE für einen Bot wehtut

Ende-zu-Ende-Verschlüsselung ist für Menschen ein Segen und für einen langlebigen, automatisierten Account eine Quelle subtiler Schmerzen. Der Grund liegt in der Natur des Krypto-Zustands: Geräte-Identität, One-Time-Keys und Cross-Signing-Schlüssel sind gerätegebunden und müssen lokal und serverseitig konsistent bleiben.

ZeroClaw nutzt das matrix-rust-sdk , dessen Krypto-Store auf genau ein Gerät ausgelegt ist und annimmt, dass der lokale Zustand mit dem Homeserver synchron bleibt. Dieser Store liegt auf dem PVC des Pods unter state/matrix. Und hier beginnt das Drama: Wird dieser Store gelöscht — sei es durch einen bewussten PVC-Wipe oder einen Unfall —, während die One-Time-Keys des alten Geräts serverseitig noch registriert sind, kann das SDK keine neuen Keys für dieselbe device_id mehr hochladen. Der Server setzt ein dauerhaftes OneTimeKeyAlreadyUploaded-Flag, und der Bot dreht sich im Kreis:

Matrix one-time key upload conflict detected; stopping sync to avoid infinite retry loop

Im Klartext: Der Matrix-Kanal verstummt. ZeroClaw kann nichts mehr entschlüsseln und nichts mehr senden. Der reflexhafte „dann lösch ich den State halt und logge neu ein" macht es nur schlimmer, solange dieselbe device_id wiederverwendet wird — man läuft direkt erneut in den Konflikt.

Die Kernlektion, teuer bezahlt: Die device_id ist im Normalbetrieb heilig und muss stabil bleiben. Ein voller PVC-Wipe ist kein „dann startet er halt neu", sondern ein geplantes Ereignis, dem zwingend eine Recovery mit einer frischen device_id folgen muss.

Das Tooling: .taskfiles/matrix/

Diesen Recovery-Tanz von Hand über die Matrix-Client-API zu machen, ist fehleranfällig und im Stress genau das, was man nicht will. Also habe ich ihn in eine Handvoll Tasks gegossen. Der entscheidende Kniff: Alle Tasks nutzen den eigenen Access-Token des Bots aus dem SOPS-Secret — kein Synapse-Admin-Token nötig, sie fassen nur den @claw-Account an.

Der schlanke Diagnose-Teil verschafft erst einmal Überblick:

task matrix:bot-info       # Homeserver / User / aktive device_id aus SOPS
task matrix:bot-devices    # alle Geräte des Bots über die Client-API auflisten

Das Herzstück ist recover-e2ee. Es meldet den Bot per Passwort-Login mit einer frisch generierten device_id an, umgeht so den One-Time-Key-Konflikt und schreibt den neuen Token samt Geräte-ID zurück ins SOPS-Secret:

task matrix:recover-e2ee   # neue device_id + Token, SOPS-Secret wird neu geschrieben

Weil das Secret Flux-verwaltet ist, schließt sich der Kreis über GitOps: committen, Flux reconcilen lassen, Pod neu starten. Der gesamte Ablauf:

flowchart TB WIPE["PVC-/Krypto-Store-Verlust"] --> CONF["One-Time-Key-Konflikt<br/>Kanal verstummt"] CONF --> REC["task matrix:recover-e2ee<br/>Passwort-Login, NEUE device_id"] REC --> SOPS["SOPS-Secret neu geschrieben<br/>(MATRIX_DEVICE_ID + ACCESS_TOKEN)"] SOPS --> GIT["commit → Flux reconcile → rollout restart"] GIT --> OK["Sync sauber, E2EE wiederhergestellt"] OK --> PRUNE["task matrix:prune-bot-devices<br/>alte Geräte entfernen"]

Der Stolperstein nach der Recovery

Eine Recovery hinterlässt das alte Gerät serverseitig — und wer recover-e2ee mehrfach laufen lässt, stapelt tote Geräte auf. Das ist nicht kosmetisch: Mehrere parallele Bot-Geräte produzieren InvalidSignature-Fehler im Olm-Protokoll und können die Auslieferung von Room-Keys blockieren, bis der eigene Client nur noch „Waiting for this message" anzeigt. Deshalb gibt es prune-bot-devices, das alle Geräte bis auf das aktive löscht — abgesichert über die User-Interactive-Auth der Matrix-API mit dem Bot-Passwort:

task matrix:prune-bot-devices   # behält nur MATRIX_DEVICE_ID, löscht den Rest
Ein letzter, gemeiner Sonderfall: Ist der Bot wieder gesund (ein einziges Gerät, Cross-Signing intakt), zeigt der eigene Client aber trotzdem „Waiting for this message", liegt das Problem auf der Empfängerseite. Dann hilft nur, die eigene Element-Session einmal aus- und wieder einzuloggen, damit sie die aktuellen Room-Keys des Bots neu zieht. Nachrichten von vor dem Reset bleiben unwiederbringlich unentschlüsselbar — neue funktionieren.

Fazit

Einen Matrix-Homeserver zu betreiben ist einfach; einen verschlüsselten Bot-Account über Pod-Neustarts und PVC-Wipes hinweg gesund zu halten, ist die eigentliche Kunst. Die Erkenntnis war, dass E2EE-Recovery kein manueller Notfalleinsatz sein darf, sondern ein deklarativer, wiederholbarer Vorgang — drei Tasks, die den Bot in Minuten statt in einer durchwachten Nacht wieder sprechen lassen. Genau dafür ist Tooling da: damit der seltene, komplizierte Fall trotzdem langweilig bleibt.