Inhoudsopgave
- Waarom koppeling bestaat
- Vertrouwensmodel en cryptografie
- Componentenoverzicht
- Ontdekkingsfase
- Koppelingsvolgorde (happy path)
- Details van sleuteluitwisseling
- Accepteren/weigeren-flow van de responder
- Annuleringsflow
- Dual-channel levering (LAN + BLE)
- Sessie- en peer-opslag
- Beveiligingseigenschappen
- Samenvatting van de toestandsautomaat
Waarom koppeling bestaat {#why-pairing-exists}
PlainApp heeft geen centrale accountserver. Apparaten moeten daarom twee vragen beantwoorden voordat ze kunnen communiceren:
- "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. - "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.
Vertrouwensmodel en cryptografie {#trust-model--cryptography}
Koppeling gebruikt twee onafhankelijke cryptografische primitieven:
| Primitief | Doel | Levenscyclus |
|---|---|---|
| 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:
- Een mens tikt Accepteren op het responder-apparaat (de gebruiker stelt vast "ja, dit is het apparaat waarmee ik wil koppelen").
- 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).
- 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/):
Bestandslocaties
| Component | Pad (onder 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 |
Ontdekkingsfase {#discovery-phase}
Voordat koppeling kan plaatsvinden, moeten apparaten elkaar vinden.
LANDiscoverManager draait continu zodra de app start:
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
toIdontcijferen, 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:
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.
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.
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.
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.
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:
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
clientIdclaimt 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 volledigeclientIdwordt alleen onthuld aan apparaten waarmee je daadwerkelijk koppelt.
Beveiligingseigenschappen {#security-properties}
| Eigenschap | Hoe het wordt bereikt |
|---|---|
| Vertrouwelijkheid | Alle transport is ChaCha20-versleuteld met de via ECDH afgeleide gedeelde sleutel. De sleutel verlaat na koppeling nooit de twee apparaten. |
| Authenticatie | Elk ondertekend bericht (koppelingsverzoek/antwoord, chat createChatItem, kanaal invite/update/kick) wordt via Ed25519 geverifieerd tegen de opgeslagen public_key van de afzender. |
| Integriteit | Ed25519-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-weerstand | De 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-weerstand | onDatagram 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. |
| Identiteitsstabiliteit | clientId 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}
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.