Inhaltsverzeichnis
- Warum es Pairing gibt
- Vertrauensmodell & Kryptografie
- Komponentenübersicht
- Discovery-Phase
- Pairing-Sequenz (Happy Path)
- Details zum Schlüsselaustausch
- Accept/Decline-Ablauf auf Responder-Seite
- Cancel-Ablauf
- Dual-Channel-Auslieferung (LAN + BLE)
- Session- und Peer-Speicherung
- Sicherheitseigenschaften
- Zusammenfassung des Zustandsautomaten
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:
- „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. - „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.
Vertrauensmodell & Kryptografie {#trust-model--cryptography}
Pairing verwendet zwei unabhängige kryptografische Primitive:
| Primitiv | Zweck | Lebenszyklus |
|---|---|---|
| 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:
- 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").
- 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).
- 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/):
Dateipfade
| Komponente | Pfad (unter shared/src/commonMain/kotlin/com/ismartcoding/plain/) |
|---|---|
LANDiscoverManager | discover/LANDiscoverManager.kt |
PairingCore | discover/PairingCore.kt |
PairingInitiator | discover/PairingInitiator.kt |
PairingResponder | discover/PairingResponder.kt |
PairingSecurity | discover/PairingSecurity.kt |
PairingSessionStore | discover/PairingSessionStore.kt |
PairingPeerStore | discover/PairingPeerStore.kt |
PairingMessenger | discover/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:
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
toIdentschlü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:
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.
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.
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.
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.
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:
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
clientIdbeansprucht, 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 volleclientIdwird nur Geräten offenbart, mit denen Sie tatsächlich pairen.
Sicherheitseigenschaften {#security-properties}
| Eigenschaft | Wie erreicht |
|---|---|
| Vertraulichkeit | Der 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. |
| Authentifizierung | Jede signierte Nachricht (Pairing-Anfrage/-Antwort, Chat createChatItem, Channel invite/update/kick) wird per Ed25519 gegen den gespeicherten public_key des Senders verifiziert. |
| Integrität | Ed25519-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-Widerstand | Der 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-Widerstand | onDatagram 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ät | clientId 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}
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.