Zurück zum Blog
Security10 min read

Pairing-Ablauf

Dieser Artikel erklärt, wie zwei PlainApp-Geräte erstmals Vertrauen zueinander aufbauen — wie sie sich gegenseitig entdecken, Schlüssel austauschen und zum gemeinsamen ChaCha20-Transportschlüssel gelangen, mit dem danach jede Chat-Nachricht, jeder Dateitransfer und jeder Presence-Ping verschlüsselt wird. Die Chat- und Channel-Architektur, die diesen Schlüssel verwendet, ist im separaten Artikel zur Chat-Architektur behandelt.

Inhaltsverzeichnis

Warum es Pairing gibt {#why-pairing-exists}

PlainApp hat keinen zentralen Account-Server. Geräte müssen daher zwei Fragen beantworten, bevor sie miteinander kommunizieren können:

  1. „Wer bist du?" — jedes Gerät erzeugt beim ersten Start eine stabile clientId (eine 13 Zeichen lange ID, die aus dem Ed25519-Schlüsselmaterial abgeleitet wird). Dies ist der einzige Bezeichner, der für Routing, Presence und Channel-Mitgliedschaft verwendet wird.
  2. „Kann ich dir vertrauen?" — ohne einen Server, der für die Identität bürgt, gibt es nur einen Weg, um sicherzustellen, dass ein Peer wirklich der ist, der er vorgibt zu sein: ein Mensch bestätigt das Pairing auf beiden Geräten, und das Protokoll verifiziert die kryptografischen Signaturen.

Pairing erzeugt ein einziges Artefakt: eine DPeer-Zeile in der Datenbank mit status="paired", einem ChaCha20-key (das gemeinsame Transport-Geheimnis) und dem Ed25519-public_key des Peers (zur Verifikation künftiger Nachrichtensignaturen). Alle späteren Protokolle im Chat-Subsystem setzen voraus, dass diese beiden Felder existieren.

Diagram 1
1

Vertrauensmodell & Kryptografie {#trust-model--cryptography}

Pairing verwendet zwei unabhängige kryptografische Primitive:

PrimitivZweckLebenszyklus
Ed25519 (Signatur)Authentifiziert die Pairing-Anfrage und -Antwort. Verifiziert „das wirklich von dem Gerät stammt, das es zu senden vorgibt" und bindet den Zeitstempel, um Replay zu verhindern.Der Signierschlüssel ist der langfristige Identitätsschlüssel des Geräts. Seine öffentliche Hälfte wird als DPeer.public_key gespeichert und später von PeerChatParser.decrypt verwendet, um jede Chat-Nachrichtensignatur zu verifizieren.
X25519-artiges ECDH (Schlüsselvereinbarung)Erzeugt ein gemeinsames Geheimnis, das zum ChaCha20-Transportschlüssel wird. Beide Geräte berechnen dasselbe Geheimnis, ohne es jemals zu übertragen.Flüchtiges Schlüsselpaar pro Pairing-Session, sofort nach Berechnung des gemeinsamen Schlüssels verworfen. Das resultierende 32-Byte-Geheimnis wird als DPeer.key gespeichert und für die Lebensdauer des Pairing wiederverwendet.

Es gibt keine PIN, keinen QR-Code, keinen Out-of-Band-Code. Vertrauen wird aufgebaut durch:

  1. Ein Mensch tippt auf dem Responder-Gerät auf Accept (der Nutzer bestätigt: „ja, das ist das Gerät, mit dem ich pairen möchte").
  2. Beide Seiten verifizieren die Ed25519-Signatur des jeweils anderen auf Anfrage/Antwort (beweist, dass der Responder mit demselben Gerät spricht, das die Session gestartet hat, und umgekehrt).
  3. Ein ±5-Minuten-Zeitstempelfenster auf beiden Nachrichten (verhindert Replay eines alten, mitgeschnittenen Handshakes).

Die Asymmetrie ist wichtig: Eine einzige menschliche Bestätigung wäre verwundbar gegenüber einem Man-in-the-Middle (der Angreifer könnte mit beiden Seiten getrennt pairen). Die Ed25519-Signatur auf dem ECDH-Public-Key verhindert das — der Responder verifiziert, dass die Anfrage von demselben Ed25519-Schlüssel signiert wurde, der die Session initiiert hat, und umgekehrt. So kann ein MITM seinen eigenen ECDH-Schlüssel nicht transparent ersetzen, ohne auch den langfristigen Signierschlüssel zu kontrollieren.

Komponentenübersicht {#component-map}

Der gesamte Pairing-Code liegt im discover/-Package (nicht in chat/peer/pair/):

Diagram 2
2

Dateipfade

KomponentePfad (unter shared/src/commonMain/kotlin/com/ismartcoding/plain/)
LANDiscoverManagerdiscover/LANDiscoverManager.kt
PairingCorediscover/PairingCore.kt
PairingInitiatordiscover/PairingInitiator.kt
PairingResponderdiscover/PairingResponder.kt
PairingSecuritydiscover/PairingSecurity.kt
PairingSessionStorediscover/PairingSessionStore.kt
PairingPeerStorediscover/PairingPeerStore.kt
PairingMessengerdiscover/PairingMessenger.kt

Discovery-Phase {#discovery-phase}

Bevor Pairing stattfinden kann, müssen sich die Geräte gegenseitig finden. LANDiscoverManager läuft kontinuierlich, sobald die App startet:

Diagram 3
3

Warum die gerichtete Discovery verschlüsselt ist

Der Broadcast-DISCOVER verrät nichts Sensibles (nur fromId=clientId), daher ist es unbedenklich, wenn jedes Gerät im LAN ihn sieht. Die gerichtete Variante wird jedoch verwendet, wenn ein Gerät die clientId eines anderen bereits kennt (z.B. es ist gepaart, aber die IP des Peers hat sich geändert) und es aufwecken möchte. Das Verschlüsseln der Ziel-clientId mit dem gemeinsamen Schlüssel des Peers bedeutet:

  • Der richtige Peer kann das toId entschlüsseln, sich selbst erkennen und antworten.
  • Alle anderen Geräte im LAN sehen nur Ciphertext — sie können nicht aufzählen, welche clientIds der Sender zu erreichen versucht.

Das ist eine kleine, aber reale Privacy-Eigenschaft: passive LAN-Beobachter können keinen Graphen darüber erstellen, wer mit wem gepaart ist.

Aware-Flags in der Antwort

Das DISCOVER_REPLY transportiert awareSupported und awareRunning. Diese werden nicht in der Datenbank persistiert — sie werden im In-Memory-PeerCacher gespeichert und bei jeder Antwort aktualisiert (sowie aus dem BLE Scan-Response-serviceData). Die Transportebene fragt sie ab, um zu entscheiden, ob ein Wi-Fi Aware-Link versucht oder direkt auf BLE gesprungen werden soll.

Pairing-Sequenz (Happy Path) {#pairing-sequence-happy-path}

Der End-to-End-Ablauf, wenn beide Geräte im selben LAN sind und der Nutzer das Pairing akzeptiert:

Diagram 4
4

Warum beide Seiten den Peer unabhängig speichern

Beachten Sie, dass sowohl der Initiator (Schritt 9) als auch der Responder (Schritt 7) PairingPeerStore.save(...) für das andere Gerät aufrufen. Das ist bewusst so: Jedes Gerät erhält am Ende eine DPeer-Zeile, die über die clientId des jeweils anderen geschlüsselt ist und die eigene Kopie des gemeinsamen ChaCha20-Schlüssels sowie den Ed25519-Public-Key des anderen enthält. Es gibt keine zentrale Registry — das Pairing ist symmetrisch und in sich geschlossen.

Warum der Responder den Schlüssel zuerst berechnet

acceptPairingRequest des Responders berechnet den gemeinsamen Schlüssel sofort bei Annahme und persistiert ihn. Das bedeutet, dass der Responder verschlüsselten Verkehr empfangen kann, bevor die Antwort beim Initiator eingetroffen ist. Wenn die Antwort auf dem Transportweg verloren geht, ist der Responder dennoch gepaart — nur der Initiator muss es erneut versuchen.

Details zum Schlüsselaustausch {#key-exchange-details}

Der kryptografische Kern des Pairing ist eine X25519-artige ECDH-Schlüsselvereinbarung, jedoch mit einer Ed25519-Signatur daraufgelegt, die sie authentifiziert.

Diagram 5
5

Was die Signatur tatsächlich schützt

Die signierte Nutzlast (toSignatureData()) ist eine kanonische Verkettung der stabilen Anfragefelder: fromId, fromName, port, deviceType, ecdhPublicKey, signaturePublicKey, timestamp und ips. Indem der ecdhPublicKey zusammen mit dem langfristigen signaturePublicKey signiert wird, bindet das Protokoll den flüchtigen Schlüssel an die Identität des Geräts. Ein Angreifer kann seinen eigenen ECDH-Public-Key beim Transport nicht ersetzen, ohne die Signatur ungültig zu machen — und er kann die Signatur nicht fälschen, ohne den langfristigen Ed25519-Schlüssel zu kontrollieren.

Genau das besiegt einen Man-in-the-Middle: Selbst wenn der Angreifer jedes Paket zwischen den beiden Geräten weiterleitet, kann er den verschlüsselten Verkehr nicht lesen (weil er keinen ECDH-Private-Key einer Seite hat) und er kann seine eigenen ECDH-Schlüssel nicht ersetzen (weil die Signaturen brechen würden).

Accept/Decline-Ablauf auf Responder-Seite {#responder-acceptdecline-flow}

Die Responder-Seite blendet bei Eintreffen eines PAIR_REQUEST einen UI-Dialog ein. Der Nutzer kann entweder akzeptieren oder ablehnen.

Diagram 6
6

Warum der Responder PairingSuccessEvent sofort bei Accept feuert

acceptPairingRequest des Responders ruft PairingPeerStore.save(...) auf und feuert PairingSuccessEvent, bevor die Antwort gesendet wird. Das ist bewusst: Falls die Antwort den Initiator nie erreicht (Netzwerkstörung), ist der Responder dennoch gepaart — beim nächsten Pairing-Versuch des Initiators wird die bereits vorhandene DPeer-Zeile des Responders vom Presence-System aufgegriffen. Der Initiator versucht es einfach erneut; der Responder muss nicht erneut bestätigen.

Cancel-Ablauf {#cancel-flow}

Beide Seiten können ein laufendes Pairing abbrechen.

Diagram 7
7

Beachten Sie, dass DPairingCancel nur über LAN-Unicast gesendet wird (der Initiator hat die IP des Responders bereits aus der Discovery-Phase), während die Decline-Antwort über sowohl LAN als auch BLE gesendet wird, weil der Responder nicht sicher weiß, über welchen Transport der Initiator erreichbar ist.

Dual-Channel-Auslieferung (LAN + BLE) {#dual-channel-delivery-lan--ble}

Wenn der Responder die DPairingResponse sendet, tut er das gleichzeitig über LAN und BLE. Der Initiator akzeptiert die erste Kopie und verwirft das Duplikat stillschweigend.

Diagram 8
8

Warum BlePairingSessionStore existiert

Wenn ein PAIR_REQUEST über BLE eintrifft, hat der Responder keine LAN-IP für den Initiator — nur dessen BLE-MAC-Adresse. BlePairingSessionStore bildet peerId → MAC ab, sodass die Antwort bei Bedarf über BLE zurückgeroutet werden kann. Das ist eine kleine, In-Memory-, flüchtige Map, die nur für über BLE geroutete Anfragen gefüllt und nach dem Senden der Antwort geleert wird.

Session- und Peer-Speicherung {#session--peer-storage}

An der Pairing beteiligen sich zwei Stores mit sehr unterschiedlichen Lebenszyklen:

Diagram 9
9

Warum clientId der einzige persistierte Bezeichner ist

Android randomisiert die BLE-MAC-Adresse bei jeder Verbindung, weshalb ihre Speicherung nutzlos wäre. Die clientId wird aus dem langfristigen Ed25519-Schlüsselmaterial des Geräts abgeleitet, also ist sie:

  • Stabil über App-Neuinstallationen hinweg (der Schlüssel liegt im Platform-Keystore).
  • Selbstauthentifizierend — jeder, der eine clientId beansprucht, muss nachweisen, dass er den entsprechenden Ed25519-Private-Key besitzt (verifiziert bei jeder signierten Nachricht).
  • Privacy-schützend — nur ein 8-Byte-SHA-256-Präfix (shortId) wird je über BLE zur Discovery gesendet; die volle clientId wird nur Geräten offenbart, mit denen Sie tatsächlich pairen.

Sicherheitseigenschaften {#security-properties}

EigenschaftWie erreicht
VertraulichkeitDer gesamte Transport ist mit ChaCha20 verschlüsselt, mit dem per ECDH abgeleiteten gemeinsamen Schlüssel. Der Schlüssel verlässt nach dem Pairing niemals die beiden Geräte.
AuthentifizierungJede signierte Nachricht (Pairing-Anfrage/-Antwort, Chat createChatItem, Channel invite/update/kick) wird per Ed25519 gegen den gespeicherten public_key des Senders verifiziert.
IntegritätEd25519-Signaturen decken den vollständigen Anfrage-Body ab; jede Manipulation invalidiert die Signatur.
Replay-Widerstand±5-Minuten-Zeitstempelfenster (erzwungen durch PeerChatParser und PairingSecurity). ChatMessageReceiver.seenSignatures dedupliziert innerhalb des Fensters.
Man-in-the-Middle-WiderstandDer flüchtige ECDH-Public-Key wird zusammen mit dem langfristigen Ed25519-Public-Key signiert. Ein MITM kann seinen eigenen ECDH-Schlüssel nicht ersetzen, ohne die Signatur zu brechen.
Forward Secrecy (eingeschränkt)ECDH-Schlüsselpaare sind flüchtig pro Pairing-Session. Eine spätere Kompromittierung des langfristigen Ed25519-Schlüssels entschlüsselt keinen vergangenen Verkehr (der gemeinsame Schlüssel wird ebenfalls noch benötigt — sind aber sowohl die ECDH-Private-Keys als auch der gespeicherte DPeer.key gelöscht, können vergangene Mitschnitte nicht entschlüsselt werden).
DoS-WiderstandonDatagram umschließt jede Nachricht in try/catch, sodass ein fehlerhaftes Paket den Discovery-Empfänger nicht killen kann. PeerCircuitBreaker überspringt einen unzuverlässigen Transport für 30 s nach 2 Fehlern.
Privacy (gerichtete Discovery)LANDiscoverManager.discoverSpecificDevice verschlüsselt die Ziel-clientId mit dem Schlüssel des Peers — passive LAN-Beobachter können nicht aufzählen, wer mit wem gepaart ist.
IdentitätsstabilitätclientId wird aus dem langfristigen Ed25519-Schlüsselmaterial im Platform-Keystore abgeleitet — stabil über Neuinstallationen, selbstauthentifizierend und nicht an Telefonnummer oder E-Mail gebunden.

Wogegen Pairing NICHT verteidigt

  • Physische Gerätekompromittierung. Wenn ein Angreifer Root-Zugriff auf einem gepaarten Gerät erlangt, kann er den gemeinsamen Schlüssel aus der Datenbank lesen und diesen Peer imitieren. Es gibt keine hardwaregestützte Keystore-Erzwingung für den gemeinsamen Transportschlüssel (nur für den Ed25519-Signierschlüssel, über SignatureHelper).
  • Aktive Relay-Angriffe. Ein Angreifer, der gleichzeitig BLE- und LAN-Verkehr zwischen zwei Geräten weiterleiten kann, die glauben, miteinander zu pairen, könnte sich theoretisch in die Mitte schalten — die Ed25519-Signatur auf dem ECDH-Public-Key bedeutet aber, dass er den Verkehr nicht lesen, sondern nur weiterleiten kann. Das ist derselbe Trade-off wie bei Bluetooth-Pairing ohne Numeric Comparison.
  • Blockieren auf Netzwerkebene. Eine Firewall kann UDP-Multicast blockieren, BLE kann gestört werden und Wi-Fi Aware kann unavailable sein. Das System degradiert graceful (BLE ist der garantierte Fallback für gepaarte Peers), kann aber ein aktiv feindliches Netzwerk nicht umgehen.

Zusammenfassung des Zustandsautomaten {#state-machine-recap}

Diagram 10
10

Weiterführende Literatur

  • Chat-Architektur — wofür der gemeinsame Schlüssel verwendet wird: Peer-Chat Send/Empfang, Channel-Fan-out, Presence, Datei-Downloads.
  • apitest/groups/discovery.sh — ausführbarer Testplan, der die Discovery- und Pairing-API-Oberfläche End-to-End testet.