Sicherheit & Messaging

doublethink, in Commits erzählt

Ich habe bereits geschrieben, warum es doublethink gibt. Das hier ist die andere Hälfte: wie es tatsächlich entstanden ist, direkt aus der Git-Historie gelesen, samt Sackgassen. Am Ende gibt es eine Live-Demo, die du in deinem eigenen Browser ausführen kannst.

Ramazan Yavuz
Ramazan Yavuz ·
doublethink, erzählt in Commits

Die Git-Historie eines Projekts ist meist ehrlicher als seine README. Die README ist das, was die Leute über das fertige Ding glauben sollen; die Commit-Historie ist das, was tatsächlich passiert ist, der Reihe nach, einschließlich der Teile, die man lieber überspringen würde. doublethink ist ein kleiner Publish/Subscribe-Message-Broker, den ich gebaut habe, so einfach aufzusetzen wie ntfy, aber mit wirklich privaten Kanälen, die der Broker selbst nicht lesen kann. Ich habe bereits darüber geschrieben, warum es das gibt. Das hier ist das Making-of, Commit für Commit durchgegangen, falsche Abzweigungen inklusive. Jeder kurze Hash unten verlinkt auf den tatsächlichen Commit auf GitHub, sodass du den Diff selbst lesen kannst.


Es begann mit einem Dokument, nicht mit Code. Der allererste Commit initialisiert das Repository mit den Spezifikationen, der Vorab-Recherche und dem Design des ersten Meilensteins. Ich mache das bewusst: bevor ich eine Zeile schrieb, hielt ich fest, wofür die Sache da war, und prüfte dann, ob sie überhaupt existieren musste. Die Recherche stellte eine Frage. Gibt es bereits einen Broker, der sowohl so einfach wie ntfy als auch wirklich privat ist? Nachdem ich mir die Zugriffskontrolle von ntfy, MQTT-Broker, NATS und die gehosteten Dienste angesehen hatte, lautete die Antwort nein: die beiden Eigenschaften gehen überall, wo ich nachsah, zu Lasten der jeweils anderen. Das ist der Grund, warum die nächsten dreißig Commits geschrieben wurden.

Diese fünf Commits sind der erste Meilenstein: ein funktionierender Kern. Ein Nachrichten-Envelope mit fester Form, der Broker, der Nachrichten verteilt, die Krypto, die im Client läuft, der Netzwerk-Transport und ein Kommandozeilen-Werkzeug, um das alles anzusteuern. Am Ende davon konnte doublethink eine Nachricht befördern. Es war nur noch von niemandem außer mir vertrauenswürdig, und die Art, wie es um Vertrauen bat, stellte sich als falsch heraus.


Die Sackgasse steht direkt im Log. Ein Commit lautet Keep keypair auth (deviate from mock contract); add MITM-resistant pairing. Die Idee war die schwergewichtige: jede Partei trägt ein kryptografisches Geräte-Schlüsselpaar, der Broker authentifiziert Verbindungen über eine signierte Challenge, und zwei Parteien zu koppeln bedeutet einen einmaligen Einladungscode plus eine kurze Zeichenkette, die beide Menschen laut vorlesen, um einen Man-in-the-Middle auszuschließen. Auf dem Papier ist das das starke Design. Ich baute das Ganze und versuchte dann, es zu benutzen. Jeder private Kanal brauchte nun einen mehrstufigen Aufbau, und die eine Eigenschaft, die ntfy zum Vorbild machte, dass du in Minuten ein Werkzeug darauf richten kannst, war weg. Ich hatte einen sicheren Broker gebaut, der nicht mehr einfach war, was hieß, dass ich etwas nachgebaut hatte, das bereits existierte.

Also löscht der nächste Commit es. Redesign to ntfy-easy shared-secret channels (drop admin/keypair/SAS pairing). Der Ersatz ist deutlich einfacher, und die Einfachheit ist die eigentliche Funktion. Ein privater Kanal wird durch ein einziges Shared Secret mit hoher Entropie geschützt. Du erstellst einen Kanal mit einer einzigen Anfrage und bekommst das Secret zurück; du gibst es der anderen Partei über einen beliebigen vertrauenswürdigen Weg, den du bereits hast; wer das Secret hält, kann beitreten, sonst niemand. Aus diesem einen Secret leitet jede Seite mit unterschiedlichen Labels zwei unabhängige Schlüssel ab: einen speichert der Broker, um zu prüfen, ob du hinein darfst, der andere, den der Broker nie berechnen kann, verschlüsselt die eigentlichen Nachrichten. Der Broker weiß, wer beitreten darf, und leitet zuverlässig Chiffretext weiter, den er nicht lesen kann. Diese Löschung ist mein liebster Commit im Projekt. Das stark wirkende Design war das falsche, und das richtige war kleiner.


Dann folgt eine kleine Gruppe von Commits, die festhält, wie ich meine Meinung zum Packaging änderte. Es kommt zuerst als Debian-.deb mit einer systemd-Unit. Ein paar Commits später wird das zugunsten von Docker und einer einzigen nativen Binärdatei wieder herausgerissen, und die .deb und die Unit werden gelöscht. Das Log behält sowohl die erste Entscheidung als auch die Korrektur, statt eine saubere Linie vorzutäuschen, als hätte ich es von Anfang an gewusst. Eine GitHub-Pages-Seite und ein Continuous-Integration-Workflow entstehen etwa zur selben Zeit, was ungefähr der Moment ist, in dem ein persönliches Experiment anfängt, sich wie ein Projekt zu verhalten, das andere Leute nutzen könnten.


Der zweite Meilenstein ist der unspektakuläre, und es ist der, der aus einem Spielzeug etwas macht, das man ins offene Internet stellen kann. In dem Moment, in dem ein Broker für jeden erreichbar ist, hört "jeder kann einen Kanal erstellen" auf, eine charmante ntfy-Eigenschaft zu sein, und wird zu einem offenen Relay, das man Fremden in die Hand gegeben hat. Ein dichter Commit, M2: accounts, SQLite retention, TTL aging, quotas, abuse control, admin key, fügt all die Maschinerie hinzu, die eine öffentliche Instanz tatsächlich braucht: leichtgewichtige Accounts mit einem API-Schlüssel, optionale Nachrichten-Aufbewahrung, damit ein Peer, der offline war, sich wieder verbinden und nachladen kann, eine Time-to-Live, die alte Nachrichten altern lässt, Speicher-Quoten pro Kanal und pro Account, damit niemand die Platte vollschreibt, Rate-Limits und einen Betreiberschlüssel, der nur auf dem Server liegt. Nichts davon ist aufregend. Alles davon ist der Unterschied zwischen einer Demo und einem Dienst.

Die beiden Commits danach sind der Grund, warum dieser Artikel so enden kann, wie er endet. Einer prüft, dass die Krypto im Browser exakt dieselben Bytes erzeugt wie die Krypto in Go, geprüft statt angenommen, und öffnet den Cross-Origin-Zugriff, den ein Browser braucht. Der nächste fügt der Projektseite eine Live-Demo im Browser hinzu, sodass die Datenschutzbehauptung kein Satz mehr ist, dem man glauben muss, sondern etwas, das man passieren sehen kann.


Der dritte Meilenstein kam von einem Anwendungsfall, den das Design noch nicht abdeckte: jemandem zu erlauben, ein Topic zu erstellen, das nie abläuft, für so etwas wie einen langlebigen Feed. Daraus fallen zwei Probleme. Ein Topic, das nie abläuft, belegt auf Dauer Speicher, also kann man nicht jeden im Internet dauerhaften Platz auf der eigenen Platte anlegen lassen; es braucht das Einverständnis des Betreibers. Aber der Betreiber darf das Secret des Kanals nie erfahren, sonst ist das Datenschutzversprechen gebrochen. Die Antwort, in den M3-Commits, ist ein Grant-Ticket: der Betreiber stellt ein einmal nutzbares, zeitlich begrenztes Ticket aus, das einen dauerhaften, gedeckelten Kanal autorisiert, und der Nutzer löst es mit seinem eigenen Secret ein. Der Betreiber autorisiert den Kanal, ohne das Secret je zu sehen. Es gibt noch einen Folgefall: wenn das Topic öffentlich sein soll, bringt es nichts, für Ende-zu-Ende-Verschlüsselung zu zahlen, also wurde der Grant verschlüsselungsneutral, und die Wahl wanderte zu dem, dem die Daten gehören, getroffen beim Einlösen. Irgendwo hier wurde die Speicher-Engine von SQLite auf Redis umgestellt, weil die Last hoch im Durchsatz statt hoch im Volumen ist, und ich hielt genau fest, wie viel ein harter Absturz verlieren kann, etwa eine Sekunde, denn "dauerhaft" sollte heißen, dass die Daten nicht altern, nicht ein Haltbarkeitsversprechen, das die Platte nicht halten kann.

Der vorletzte Commit, der aufhört, interne Planungsdokumente zu veröffentlichen, ist eine kleine Lektion über das Veröffentlichen: das Repository, in dem du entwickelst, und das Repository, das du veröffentlichst, sind nicht dasselbe, und zu wählen, was man weglässt, ist Teil der Arbeit. Der Moment in dieser Historie, über den ich am meisten nachdenke, ist gar keine Funktion. Es ist ein Secret-Scan, der mich erwischte, kurz bevor ich einen echten Admin-Schlüssel als Test-Fixture in ein öffentliches Repo committet hätte. Die Kernidee von doublethink ist kurz. Der größte Teil des Aufwands, der es vertrauenswürdig macht, steckt in den unspektakulären Teilen drumherum.


Also, die Demo. Die Projektseite trägt eine einzige Live-Demo, die vollständig in deinem Browser läuft und mit einer echten Instanz auf meinem eigenen Server spricht. Sie führt den verschlüsselten Hin- und Rückweg vor deinen Augen aus: dein Browser erzeugt ein Secret, erstellt einen Wegwerf-Kanal, sendet eine Nachricht und zeigt dir sowohl den exakten Chiffretext, den der Broker weiterleitet, als auch den Klartext, den nur der Secret-Halter wiederherstellen kann. Dann versucht sie dieselben Bytes mit einem falschen Secret und stellt nichts wieder her. Der Kanal ist flüchtig und wird sofort abgebaut, sodass nichts gespeichert wird. Unter der Demo hat die Seite Befehle zum Kopieren und Einfügen, um den Broker selbst auszuführen und vom Terminal aus mit einfachem curl zu publizieren und zu abonnieren. Das ist die ganze Projektseite: eine Demo, die du ansehen, und Befehle, die du ausführen kannst, mehr nicht.

Sieh es laufen: die Demo im Browser und die Befehle zum Ausführen sind auf der doublethink-Projektseite. Der vollständige Quellcode, samt jedem oben verlinkten Commit, liegt auf GitHub.

Das ist das Projekt, so wie es gebaut wurde: eine Recherche-Notiz vor jedem Code, ein stärker wirkendes Design, das zugunsten eines einfacheren fallen gelassen wurde, eine zurückgenommene Packaging-Entscheidung, der unspektakuläre Meilenstein, der das Deployen sicher machte, eine Funktion für dauerhafte Topics und ein Secret-Scan, der einen echten Fehler verhinderte. doublethink ist Open Source, läuft in Docker oder als einzelne Binärdatei und trägt einen schlichten Haftungsausschluss, denn ein Werkzeug, dessen Aufgabe es ist, den privaten Verkehr anderer Leute zu tragen, sollte ehrlich sein, dass es die Arbeit einer einzelnen Person ist, geprüft, aber nicht unfehlbar. Die Commit-Historie ist der genaueste Bericht darüber, wie es hierher kam, und deshalb habe ich diesen Text aus ihr geschrieben.