Table des matières
- Pourquoi le jumelage existe
- Modèle de confiance et cryptographie
- Carte des composants
- Phase de découverte
- Séquence de jumelage (chemin nominal)
- Détails de l'échange de clés
- Flux d'acceptation/refus côté répondeur
- Flux d'annulation
- Livraison double-canal (LAN + BLE)
- Stockage des sessions et pairs
- Propriétés de sécurité
- Récapitulatif de la machine à états
Pourquoi le jumelage existe {#why-pairing-exists}
PlainApp n'a aucun serveur central de comptes. Les appareils doivent donc répondre à deux questions avant de pouvoir communiquer :
- « Qui es-tu ? » — chaque appareil génère un
clientIdstable lors de son premier lancement (un identifiant de 13 caractères dérivé du matériel de clé Ed25519). C'est le seul identifiant utilisé pour le routage, la présence et l'appartenance aux canaux. - « Puis-je te faire confiance ? » — sans serveur pour garantir l'identité, la seule façon d'être certain qu'un pair est bien celui qu'il prétend être est qu'un humain confirme le jumelage sur les deux appareils et que le protocole vérifie les signatures cryptographiques.
Le jumelage produit un artefact unique : une ligne DPeer dans la base de
données avec status="paired", une key ChaCha20 (le secret de transport
partagé) et la public_key Ed25519 du pair (pour vérifier les signatures
futures des messages). Chaque protocole ultérieur du sous-système de chat
suppose que ces deux champs existent.
Modèle de confiance et cryptographie {#trust-model--cryptography}
Le jumelage utilise deux primitives cryptographiques indépendantes :
| Primitive | Rôle | Cycle de vie |
|---|---|---|
| Ed25519 (signature) | Authentifier la requête et la réponse de jumelage. Vérifie « ce message provient bien de l'appareil qui prétend l'avoir envoyé » et lie l'horodatage pour empêcher les rejeux. | La clé de signature est la clé d'identité à long terme de l'appareil. Sa moitié publique est stockée comme DPeer.public_key et ultérieurement utilisée par PeerChatParser.decrypt pour vérifier la signature de chaque message de chat. |
| ECDH de type X25519 (accord de clé) | Produire un secret partagé qui devient la clé de transport ChaCha20. Les deux appareils calculent le même secret sans jamais le transmettre. | Paire de clés éphémère générée par session de jumelage, détruite immédiatement après calcul du secret partagé. Le secret de 32 octets obtenu est stocké comme DPeer.key et réutilisé pendant toute la durée de vie du jumelage. |
Il n'y a ni PIN, ni QR code, ni code hors-bande. La confiance est établie par :
- Un humain qui appuie sur Accepter sur l'appareil répondeur (l'utilisateur affirme « oui, c'est l'appareil avec lequel je veux me jumeler »).
- Les deux parties qui vérifient la signature Ed25519 de l'autre sur la requête/réponse (prouvant que le répondeur discute avec le même appareil qui a initié la session et vice-versa).
- Une fenêtre d'horodatage de ±5 min sur les deux messages (empêchant le rejeu d'une poignée de main anciennement capturée).
L'asymétrie compte : une simple confirmation humaine serait vulnérable à une attaque de l'homme du milieu (l'attaquant pourrait se jumeler séparément avec les deux parties). La signature Ed25519 sur la clé publique ECDH empêche cela — le répondeur vérifie que la requête a été signée par la même clé Ed25519 qui a initié la session, et vice-versa, de sorte qu'un MITM ne peut pas substituer sa propre clé ECDH sans contrôler la clé de signature à long terme.
Carte des composants {#component-map}
Tout le code de jumelage se trouve dans le paquet discover/ (et non dans
chat/peer/pair/) :
Emplacement des fichiers
| Composant | Chemin (sous 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 |
Phase de découverte {#discovery-phase}
Avant que le jumelage puisse se faire, les appareils doivent se trouver.
LANDiscoverManager tourne en continu dès le démarrage de l'application :
Pourquoi la découverte dirigée est chiffrée
Le DISCOVER diffusé ne révèle rien de sensible (juste fromId=clientId),
donc n'importe quel appareil sur le LAN peut le voir. La variante dirigée,
en revanche, est utilisée lorsqu'un appareil connaît déjà le clientId d'un
autre (par ex. ils sont jumelés mais l'IP du pair a changé) et souhaite le
réveiller. Chiffrer le clientId cible avec la clé partagée du pair signifie :
- Le bon pair peut déchiffrer le
toId, se reconnaître et répondre. - Tous les autres appareils du LAN ne voient que du texte chiffré — ils ne
peuvent pas énumérer les
clientIdque l'expéditeur tente de joindre.
C'est une propriété de confidentialité modeste mais réelle : un observateur LAN passif ne peut pas construire un graphe de qui est jumelé avec qui.
Indicateurs Aware dans la réponse
Le DISCOVER_REPLY transporte awareSupported et awareRunning. Ils ne sont
pas persistés dans la base de données — ils sont stockés en mémoire dans
PeerCacher et rafraîchis à chaque réponse (et également à partir du
serviceData de la scan-response BLE). La couche de transport les consulte pour
décider s'il faut tenter une liaison Wi-Fi Aware ou passer directement à BLE.
Séquence de jumelage (chemin nominal) {#pairing-sequence-happy-path}
Le flux de bout en bout lorsque les deux appareils sont sur le même LAN et que l'utilisateur accepte le jumelage :
Pourquoi les deux côtés stockent le pair indépendamment
Notez que l'initiateur (étape 9) et le répondeur (étape 7) appellent
tous deux PairingPeerStore.save(...) pour l'autre appareil. C'est
intentionnel : chaque appareil se retrouve avec une ligne DPeer indexée par
le clientId de l'autre, contenant sa propre copie de la clé ChaCha20
partagée et la clé publique Ed25519 de l'autre. Il n'existe aucun registre
central — le jumelage est symétrique et autonome.
Pourquoi le répondeur calcule la clé en premier
Le acceptPairingRequest du répondeur calcule la clé partagée immédiatement
lors de l'acceptation et la persiste. Cela signifie que le répondeur peut
commencer à recevoir du trafic chiffré avant que la réponse ne revienne à
l'initiateur. Si la réponse est perdue en transit, le répondeur est tout de
même jumelé — seul l'initiateur doit réessayer.
Détails de l'échange de clés {#key-exchange-details}
Le cœur cryptographique du jumelage est un accord de clé ECDH classique de type X25519, mais avec une signature Ed25519 superposée pour l'authentifier.
Ce que la signature protège réellement
La charge utile signée (toSignatureData()) est une concaténation canonique
des champs stables de la requête : fromId, fromName, port, deviceType,
ecdhPublicKey, signaturePublicKey, timestamp et ips. En signant
l'ecdhPublicKey avec la signaturePublicKey à long terme, le protocole
lie la clé éphémère à l'identité de l'appareil. Un attaquant ne peut pas
substituer sa propre clé publique ECDH en transit sans invalider la
signature — et il ne peut pas forger la signature sans contrôler la clé
Ed25519 à long terme.
C'est ce qui défait l'homme du milieu : même si l'attaquant relaie chaque paquet entre les deux appareils, il ne peut pas lire le trafic chiffré (parce qu'il n'a la clé privée ECDH d'aucune des parties) et il ne peut pas substituer ses propres clés ECDH (parce que les signatures seraient invalidées).
Flux d'acceptation/refus côté répondeur {#responder-acceptdecline-flow}
Le côté répondeur affiche une boîte de dialogue UI lorsqu'un PAIR_REQUEST
arrive. L'utilisateur peut accepter ou refuser.
Pourquoi le répondeur émet PairingSuccessEvent immédiatement à l'acceptation
Le acceptPairingRequest du répondeur appelle PairingPeerStore.save(...)
et émet PairingSuccessEvent avant d'envoyer la réponse. C'est
délibéré : si la réponse n'atteint jamais l'initiateur (problème réseau), le
répondeur est tout de même jumelé — la prochaine fois que l'initiateur
tentera de se jumeler, la ligne DPeer déjà existante du répondeur sera
récupérée par le système de présence. L'initiateur réessaie simplement ; le
répondeur n'a pas besoin de reconfirmer.
Flux d'annulation {#cancel-flow}
L'une ou l'autre des parties peut annuler un jumelage en cours.
Notez que DPairingCancel est envoyé sur LAN en unicast uniquement
(l'initiateur a déjà l'IP du répondeur depuis la phase de découverte), tandis
que la réponse de refus est envoyée à la fois sur LAN et sur BLE parce
que le répondeur ne peut pas savoir sur quel transport l'initiateur est
joignable.
Livraison double-canal (LAN + BLE) {#dual-channel-delivery-lan--ble}
Lorsque le répondeur envoie la DPairingResponse, il le fait à la fois sur
LAN et sur BLE simultanément. L'initiateur accepte la première copie et
jette silencieusement le doublon.
Pourquoi BlePairingSessionStore existe
Lorsqu'un PAIR_REQUEST arrive via BLE, le répondeur n'a pas d'IP LAN pour
l'initiateur — seulement son adresse MAC BLE. BlePairingSessionStore mappe
peerId → MAC de sorte que la réponse puisse être routée en retour via BLE
si nécessaire. C'est une petite table en mémoire, éphémère, uniquement
peuplée pour les requêtes routées via BLE et effacée une fois la réponse
envoyée.
Stockage des sessions et pairs {#session--peer-storage}
Deux magasins participent au jumelage, avec des durées de vie très différentes :
Pourquoi clientId est le seul identifiant persisté
Android randomise l'adresse MAC BLE à chaque connexion, donc la stocker
serait inutile. Le clientId est dérivé du matériel de clé Ed25519 à long
terme de l'appareil, il est donc :
- Stable à travers les réinstallations de l'application (la clé est dans le keystore de la plateforme).
- Auto-authentifiant — quiconque prétend un
clientIddoit prouver qu'il détient la clé privée Ed25519 correspondante (vérifié sur chaque message signé). - Préservateur de vie privée — seul un préfixe SHA-256 de 8 octets
(
shortId) est diffusé via BLE pour la découverte ; leclientIdcomplet n'est révélé qu'aux appareils avec lesquels vous vous jumelez réellement.
Propriétés de sécurité {#security-properties}
| Propriété | Comment elle est atteinte |
|---|---|
| Confidentialité | Tout le transport est chiffré en ChaCha20 avec la clé partagée dérivée d'ECDH. La clé ne quitte jamais les deux appareils après le jumelage. |
| Authentification | Chaque message signé (requête/réponse de jumelage, createChatItem du chat, invite/update/kick de canal) est vérifié Ed25519 contre la public_key stockée de l'expéditeur. |
| Intégrité | Les signatures Ed25519 couvrent l'intégralité du corps de la requête ; toute altération invalide la signature. |
| Résistance au rejeu | Fenêtre d'horodatage de ±5 min (appliquée par PeerChatParser et PairingSecurity). ChatMessageReceiver.seenSignatures déduplique au sein de la fenêtre. |
| Résistance à l'homme du milieu | La clé publique ECDH éphémère est signée conjointement avec la clé publique Ed25519 à long terme. Un MITM ne peut pas substituer sa propre clé ECDH sans casser la signature. |
| Confidentialité parfaite (limitée) | Les paires de clés ECDH sont éphémères par session de jumelage. Compromettre la clé Ed25519 à long terme ultérieurement ne déchiffre pas le trafic passé (la clé partagée reste également nécessaire — mais si les clés privées ECDH et le DPeer.key stocké sont effacés, les captures passées ne peuvent pas être déchiffrées). |
| Résistance au déni de service | onDatagram enveloppe chaque message dans un try/catch afin qu'un paquet malformé ne puisse pas tuer le récepteur de découverte. PeerCircuitBreaker saute un transport instable pendant 30 s après 2 échecs. |
| Confidentialité (découverte dirigée) | LANDiscoverManager.discoverSpecificDevice chiffre le clientId cible avec la clé du pair — un observateur LAN passif ne peut pas énumérer qui est jumelé avec qui. |
| Stabilité de l'identité | clientId est dérivé du matériel de clé Ed25519 à long terme dans le keystore de la plateforme — stable à travers les réinstallations, auto-authentifiant, et non lié à un numéro de téléphone ou un e-mail. |
Ce contre quoi le jumelage NE se défend PAS
- Compromission physique de l'appareil. Si un attaquant obtient root sur
un appareil jumelé, il peut lire la clé partagée dans la base de données et
se faire passer pour ce pair. Il n'y a pas d'application de keystore
matériel pour la clé de transport partagée (seulement pour la clé de
signature Ed25519, via
SignatureHelper). - Attaques par relais actif. Un attaquant capable de relayer simultanément le trafic BLE et LAN entre deux appareils qui pensent se jumeler entre eux pourrait théoriquement se placer au milieu — mais la signature Ed25519 sur la clé publique ECDH signifie qu'il ne peut pas lire le trafic, seulement le relayer. C'est le même compromis que le jumelage Bluetooth sans comparaison numérique.
- Blocage au niveau réseau. Un pare-feu peut bloquer le multicast UDP, le BLE peut être brouillé, et Wi-Fi Aware peut être indisponible. Le système se dégrade gracieusement (BLE est l'alternative garantie pour les pairs jumelés) mais ne peut pas contourner un réseau activement hostile.
Récapitulatif de la machine à états {#state-machine-recap}
Pour aller plus loin
- Architecture du Chat — à quoi sert la clé partagée : envoi/réception de chat pair-à-pair, diffusion sur canal, présence, téléchargements de fichiers.
apitest/groups/discovery.sh— plan de test exécutable qui exerce l'API de découverte et de jumelage de bout en bout.