Terug naar blog
Security10 min read

Koppelingsproces

Dit artikel legt uit hoe twee PlainApp-apparaten voor het eerst vertrouwen opbouwen — hoe ze elkaar ontdekken, sleutels uitwisselen, en uitkomen bij de gedeelde ChaCha20-transportsleutel waarmee elk chatbericht, elke bestandsoverdracht en elke presence-ping daarna wordt versleuteld. De chat- en kanaalarchitectuur die deze sleutel gebruikt, wordt behandeld in het afzonderlijke artikel Chatarchitectuur.

Inhoudsopgave

Waarom koppeling bestaat {#why-pairing-exists}

PlainApp heeft geen centrale accountserver. Apparaten moeten daarom twee vragen beantwoorden voordat ze kunnen communiceren:

  1. "Wie ben je?" — elk apparaat genereert bij de eerste lancering een stabiele clientId (een id van 13 tekens, afgeleid van zijn Ed25519- sleutelmateriaal). Dit is de enige identifier die wordt gebruikt voor routing, presence en kanaallidmaatschap.
  2. "Kan ik je vertrouwen?" — zonder server die voor de identiteit instaat, is de enige manier om er zeker van te zijn dat een peer is wie hij zegt te zijn, dat een mens de koppeling op beide apparaten bevestigt en dat het protocol cryptografische handtekeningen verifieert.

Koppeling produceert één artefact: een DPeer-rij in de database met status="paired", een ChaCha20-key (het gedeelde transportgeheim) en de Ed25519-public_key van de peer (voor het verifiëren van toekomstige bericht-handtekeningen). Elk later protocol in het chatsysteem gaat ervan uit dat deze twee velden bestaan.

Diagram 1
1

Vertrouwensmodel en cryptografie {#trust-model--cryptography}

Koppeling gebruikt twee onafhankelijke cryptografische primitieven:

PrimitiefDoelLevenscyclus
Ed25519 (handtekening)Authenticeert het koppelingsverzoek en -antwoord. Verifieert "dit komt werkelijk van het apparaat dat beweert het verzonden te hebben" en bindt de timestamp om replay te voorkomen.De ondertekeningssleutel is de langetermijn-identiteitssleutel van het apparaat. De publieke helft wordt opgeslagen als DPeer.public_key en later gebruikt door PeerChatParser.decrypt om elke bericht-handtekening te verifiëren.
X25519-stijl ECDH (sleutelovereenkomst)Produceert een gedeeld geheim dat de ChaCha20-transportsleutel wordt. Beide apparaten berekenen hetzelfde geheim zonder het ooit te verzenden.Tijdelijke sleutelpaar gegenereerd per koppelingssessie, onmiddellijk verwijderd nadat de gedeelde sleutel is berekend. Het resulterende 32-byte geheim wordt opgeslagen als DPeer.key en hergebruikt gedurende de levensduur van de koppeling.

Er is geen PIN, geen QR-code, geen out-of-band code. Vertrouwen wordt opgebouwd door:

  1. Een mens tikt Accepteren op het responder-apparaat (de gebruiker stelt vast "ja, dit is het apparaat waarmee ik wil koppelen").
  2. Beide kanten verifiëren elkaars Ed25519-handtekening op het verzoek/antwoord (waarmee wordt bewezen dat de responder communiceert met hetzelfde apparaat dat de sessie startte en omgekeerd).
  3. Een timestamp-venster van ±5 min op beide berichten (om replay van een oude, onderschepte handshake te voorkomen).

De asymmetrie is belangrijk: een enkele menselijke bevestiging zou kwetsbaar zijn voor een man-in-the-middle (de aanvaller kan met beide kanten afzonderlijk koppelen). De Ed25519-handtekening op de ECDH-publieke sleutel voorkomt dit — de responder verifieert dat het verzoek is ondertekend door dezelfde Ed25519-sleutel die de sessie startte, en omgekeerd, zodat een MITM niet onzichtbaar zijn eigen ECDH-sleutel kan substitueren zonder ook de langetermijn-ondertekeningssleutel te beheersen.

Componentenoverzicht {#component-map}

Alle koppelingscode bevindt zich in het discover/-pakket (niet chat/peer/pair/):

Diagram 2
2

Bestandslocaties

ComponentPad (onder 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

Ontdekkingsfase {#discovery-phase}

Voordat koppeling kan plaatsvinden, moeten apparaten elkaar vinden. LANDiscoverManager draait continu zodra de app start:

Diagram 3
3

Waarom gerichte ontdekking is versleuteld

De broadcast-DISCOVER onthult niets gevoeligs (alleen fromId=clientId), dus het is geen probleem als elk apparaat op de LAN het ziet. De gerichte variant wordt echter gebruikt wanneer één apparaat al de clientId van een ander kent (bijv. gekoppeld maar het IP van de peer is gewijzigd) en het wil wekken. Door de doel-clientId te versleutelen met de gedeelde sleutel van de peer geldt:

  • De juiste peer kan de toId ontcijferen, zichzelf herkennen en antwoorden.
  • Elk ander apparaat op de LAN ziet alleen ciphertext — ze kunnen niet inventariseren welke clientIds de afzender probeert te bereiken.

Dit is een kleine maar echte privacy-eigenschap: passieve LAN-waarnemers kunnen geen graaf bouwen van wie met wie gekoppeld is.

Aware-vlaggen in het antwoord

De DISCOVER_REPLY bevat awareSupported en awareRunning. Deze worden niet gepersisteerd naar de database — ze worden in-memory opgeslagen in PeerCacher en bij elke reactie ververst (en ook vanuit de BLE-scan-response serviceData). De transportlaag raadpleegt ze om te bepalen of een Wi-Fi Aware-verbinding moet worden geprobeerd of direct naar BLE moet worden overgegaan.

Koppelingsvolgorde (happy path) {#pairing-sequence-happy-path}

De end-to-end-flow wanneer beide apparaten zich op dezelfde LAN bevinden en de gebruiker de koppeling accepteert:

Diagram 4
4

Waarom beide kanten de peer onafhankelijk opslaan

Merk op dat zowel de initiator (stap 9) als de responder (stap 7) PairingPeerStore.save(...) aanroepen voor het andere apparaat. Dit is bewust: elk apparaat eindigt met een DPeer-rij, geïndexeerd op de clientId van de ander, met daarin zijn eigen kopie van de gedeelde ChaCha20-sleutel en de Ed25519-publieke sleutel van de ander. Er is geen centraal register — de koppeling is symmetrisch en zelfstandig.

Waarom de responder de sleutel eerst berekent

De acceptPairingRequest van de responder berekent onmiddellijk bij acceptatie de gedeelde sleutel en persisteert deze. Dit betekent dat de responder versleuteld verkeer kan beginnen te ontvangen voordat het antwoord terugkomt bij de initiator. Als het antwoord onderweg verloren raakt, is de responder alsnog gekoppeld — alleen de initiator moet het opnieuw proberen.

Details van sleuteluitwisseling {#key-exchange-details}

De cryptografische kern van koppeling is een standaard X25519-stijl ECDH- sleutelovereenkomst, maar met een Ed25519-handtekening er bovenop voor authenticatie.

Diagram 5
5

Wat de handtekening daadwerkelijk beschermt

De ondertekende payload (toSignatureData()) is een canonieke samenvoeging van de stabiele verzoekvelden: fromId, fromName, port, deviceType, ecdhPublicKey, signaturePublicKey, timestamp en ips. Door de ecdhPublicKey samen met de langetermijn-signaturePublicKey te ondertekenen, bindt het protocol de tijdelijke sleutel aan de identiteit van het apparaat. Een aanvaller kan zijn eigen ECDH-publieke sleutel niet in transit substitueren zonder de handtekening ongeldig te maken — en hij kan de handtekening niet vervalsen zonder de langetermijn-Ed25519-sleutel te beheersen.

Dit is wat een man-in-the-middle verslaat: zelfs als de aanvaller elk pakket tussen de twee apparaten doorgeeft, kan hij het versleutelde verkeer niet lezen (omdat hij geen van beide ECDH-privésleutels heeft) en kan hij zijn eigen ECDH-sleutels niet substitueren (omdat de handtekeningen dan zouden breken).

Accepteren/weigeren-flow van de responder {#responder-acceptdecline-flow}

De responder-kant toont een UI-dialoogvenster wanneer een PAIR_REQUEST binnenkomt. De gebruiker kan accepteren of weigeren.

Diagram 6
6

Waarom de responder PairingSuccessEvent direct bij acceptatie afvuurt

De acceptPairingRequest van de responder roept PairingPeerStore.save(...) aan en vuurt PairingSuccessEvent af voordat het antwoord wordt verzonden. Dit is bewust: als het antwoord de initiator nooit bereikt (netwerkstoring), is de responder alsnog gekoppeld — de volgende keer dat de initiator probeert te koppelen, wordt de reeds bestaande DPeer-rij van de responder opgepikt door het presence-systeem. De initiator probeert het gewoon opnieuw; de responder hoeft niet opnieuw te bevestigen.

Annuleringsflow {#cancel-flow}

Beide kanten kunnen een lopende koppeling annuleren.

Diagram 7
7

Merk op dat DPairingCancel alleen via LAN unicast wordt verzonden (de initiator heeft het IP van de responder al uit de ontdekkingsfase), terwijl het weigeringsantwoord via zowel LAN als BLE wordt verzonden omdat de responder niet zeker weet via welk transport de initiator bereikbaar is.

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

Wanneer de responder de DPairingResponse verzendt, doet hij dat gelijktijdig via zowel LAN als BLE. De initiator accepteert de eerste kopie en negeert stilzwijgend het duplicaat.

Diagram 8
8

Waarom BlePairingSessionStore bestaat

Wanneer een PAIR_REQUEST via BLE binnenkomt, heeft de responder geen LAN-IP voor de initiator — alleen het BLE MAC-adres. BlePairingSessionStore kent peerId → MAC toe, zodat het antwoord indien nodig via BLE kan worden teruggestuurd. Dit is een kleine, in-memory, tijdelijke map die alleen wordt gevuld voor via-BLE gerouteerde verzoeken en wordt gewist zodra het antwoord is verzonden.

Sessie- en peer-opslag {#session--peer-storage}

Twee stores nemen deel aan koppeling, met zeer verschillende levensduren:

Diagram 9
9

Waarom clientId de enige gepersisteerde identifier is

Android wijzigt het BLE MAC-adres bij elke verbinding, dus opslaan ervan zou zinloos zijn. De clientId wordt afgeleid van het langetermijn- Ed25519-sleutelmateriaal van het apparaat, dus deze is:

  • Stabiel bij herinstallaties van de app (de sleutel staat in de platform-keystore).
  • Zelf-authenticerend — iedereen die een clientId claimt moet bewijzen dat hij de bijbehorende Ed25519-privésleutel heeft (geverifieerd bij elk ondertekend bericht).
  • Privacy-vriendelijk — slechts een 8-byte SHA-256-prefix (shortId) wordt ooit via BLE uitgezonden voor ontdekking; de volledige clientId wordt alleen onthuld aan apparaten waarmee je daadwerkelijk koppelt.

Beveiligingseigenschappen {#security-properties}

EigenschapHoe het wordt bereikt
VertrouwelijkheidAlle transport is ChaCha20-versleuteld met de via ECDH afgeleide gedeelde sleutel. De sleutel verlaat na koppeling nooit de twee apparaten.
AuthenticatieElk ondertekend bericht (koppelingsverzoek/antwoord, chat createChatItem, kanaal invite/update/kick) wordt via Ed25519 geverifieerd tegen de opgeslagen public_key van de afzender.
IntegriteitEd25519-handtekeningen dekken de volledige verzoek-body; elke knoeienij maakt de handtekening ongeldig.
Replay-weerstand±5 min timestamp-venster (afgedwongen door PeerChatParser en PairingSecurity). ChatMessageReceiver.seenSignatures ontdubbelt binnen het venster.
Man-in-the-middle-weerstandDe tijdelijke ECDH-publieke sleutel wordt samen met de langetermijn-Ed25519-publieke sleutel ondertekend. Een MITM kan zijn eigen ECDH-sleutel niet substitueren zonder de handtekening te breken.
Forward secrecy (beperkt)ECDH-sleutelparen zijn tijdelijk per koppelingssessie. Compromittering van de langetermijn-Ed25519-sleutel ontsleutelt geen verleden verkeer (de gedeelde sleutel is ook nog nodig — maar als zowel de ECDH-privésleutels als de opgeslagen DPeer.key worden gewist, kunnen opnames uit het verleden niet worden ontsleuteld).
Denial-of-service-weerstandonDatagram verpakt elk bericht in try/catch zodat een misvormd pakket de ontvanger van ontdekking niet kan laten crashen. PeerCircuitBreaker slaat een onbetrouwbaar transport 30 s over na 2 storingen.
Privacy (gerichte ontdekking)LANDiscoverManager.discoverSpecificDevice versleutelt de doel-clientId met de sleutel van de peer — passieve LAN-waarnemers kunnen niet inventariseren wie met wie gekoppeld is.
IdentiteitsstabiliteitclientId wordt afgeleid van langetermijn-Ed25519-sleutelmateriaal in de platform-keystore — stabiel bij herinstallaties, zelf-authenticerend, en niet gekoppeld aan een telefoonnummer of e-mailadres.

Waartegen koppeling NIET verdedigt

  • Fysieke apparaatscompromittering. Als een aanvaller root-toegang krijgt op een gekoppeld apparaat, kan hij de gedeelde sleutel uit de database lezen en die peer imiteren. Er is geen hardware-ondersteunde sleutelopslag-afdwinging voor de gedeelde transportsleutel (alleen voor de Ed25519-ondertekeningssleutel, via SignatureHelper).
  • Actieve relay-aanvallen. Een aanvaller die gelijktijdig BLE- en LAN- verkeer kan doorgeven tussen twee apparaten die denken dat ze met elkaar koppelen, kan zich in theorie in het midden positioneren — maar de Ed25519-handtekening op de ECDH-publieke sleutel betekent dat hij het verkeer niet kan lezen, alleen doorgeven. Dit is dezelfde afweging als Bluetooth-koppeling zonder numerieke vergelijking.
  • Blokkering op netwerkniveau. Een firewall kan UDP-multicast blokkeren, BLE kan worden gestoord, en Wi-Fi Aware kan onbeschikbaar zijn. Het systeem degradeert sierlijk (BLE is de gegarandeerde fallback voor gekoppelde peers) maar kan een actief vijandig netwerk niet omzeilen.

Samenvatting van de toestandsautomaat {#state-machine-recap}

Diagram 10
10

Verder lezen

  • Chatarchitectuur — waarvoor de gedeelde sleutel wordt gebruikt: peer-chat verzenden/ontvangen, kanaal-fan-out, presence, bestandsdownloads.
  • apitest/groups/discovery.sh — uitvoerbaar testplan dat de ontdekkings- en koppelings-API end-to-end oefent.