Sommario
- Perché esiste il pairing
- Modello di fiducia e crittografia
- Mappa dei componenti
- Fase di discovery
- Sequenza di pairing (percorso felice)
- Dettagli dello scambio di chiavi
- Flusso Accept/Decline del responder
- Flusso Cancel
- Consegna su doppio canale (LAN + BLE)
- Storage di sessione e peer
- Proprietà di sicurezza
- Riepilogo della macchina a stati
Perché esiste il pairing {#why-pairing-exists}
PlainApp non ha un server account centrale. I dispositivi devono quindi rispondere a due domande prima di poter comunicare:
- «Chi sei?» — ogni dispositivo genera un
clientIdstabile al primo avvio (un id di 13 caratteri derivato dal materiale chiave Ed25519). È l'unico identificatore usato per routing, presenza e appartenenza ai canali. - «Posso fidarmi di te?» — senza un server che garantisca l'identità, l'unico modo per essere sicuri che un peer sia chi dichiara di essere è che una persona confermi il pairing su entrambi i dispositivi e che il protocollo verifichi le firme crittografiche.
Il pairing produce un singolo artefatto: una riga DPeer nel database con
status="paired", una key ChaCha20 (il segreto di trasporto condiviso) e
l'public_key Ed25519 del peer (per verificare le firme future dei messaggi).
Ogni protocollo successivo nel sottosistema chat dà per scontata l'esistenza di
questi due campi.
Modello di fiducia e crittografia {#trust-model--cryptography}
Il pairing usa due primitive crittografiche indipendenti:
| Primitiva | Scopo | Ciclo di vita |
|---|---|---|
| Ed25519 (firma) | Autenticare la richiesta e la risposta di pairing. Verifica «questo è davvero arrivato dal dispositivo che dichiara di averlo inviato» e vincola il timestamp per prevenire replay. | La chiave di firma è la chiave di identità di lungo termine del dispositivo. La sua metà pubblica è memorizzata come DPeer.public_key e in seguito usata da PeerChatParser.decrypt per verificare ogni firma dei messaggi di chat. |
| X25519-style ECDH (accordo di chiavi) | Produrre un segreto condiviso che diventa la chiave di trasporto ChaCha20. I due dispositivi calcolano lo stesso segreto senza mai trasmetterlo. | Coppia di chiavi effimera generata per ogni sessione di pairing, scartata immediatamente dopo aver calcolato la chiave condivisa. Il segreto di 32 byte risultante è memorizzato come DPeer.key e riusato per tutta la durata del pairing. |
Non c'è PIN, né QR code, né codice out-of-band. La fiducia viene stabilita da:
- Una persona che tocca Accept sul dispositivo responder (l'utente sta asserendo «sì, questo è il dispositivo con cui voglio fare il pairing»).
- Entrambe le parti verificano la firma Ed25519 dell'altra su richiesta/risposta (dimostrando che il responder sta parlando con lo stesso dispositivo che ha iniziato la sessione e viceversa).
- Una finestra temporale di ±5 min su entrambi i messaggi (per prevenire il replay di un handshake vecchio catturato).
L'asimmetria è importante: una singola conferma umana sarebbe vulnerabile a un man-in-the-middle (l'attaccante potrebbe fare pairing con entrambe le parti separatamente). La firma Ed25519 sulla chiave pubblica ECDH previene questo — il responder verifica che la richiesta sia stata firmata dalla stessa chiave Ed25519 che ha iniziato la sessione, e viceversa, così un MITM non può sostituire in modo trasparente la propria chiave ECDH senza controllare anche la chiave di firma di lungo termine.
Mappa dei componenti {#component-map}
Tutto il codice di pairing si trova nel package discover/ (non in
chat/peer/pair/):
Posizioni dei file
| Componente | Percorso (sotto 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 |
Fase di discovery {#discovery-phase}
Prima che il pairing possa avvenire, i dispositivi devono trovarsi a vicenda.
LANDiscoverManager gira continuamente una volta avviata l'app:
Perché il discovery direzionato è cifrato
Il DISCOVER broadcast non rivela nulla di sensibile (solo fromId=clientId), e
quindi va bene che qualsiasi dispositivo sulla LAN lo veda. La variante
direzionata, invece, viene usata quando un dispositivo già conosce il
clientId di un altro (ad es. sono paired ma l'IP del peer è cambiato) e vuole
svegliarlo. Cifrare il clientId di destinazione con la chiave condivisa del
peer significa che:
- Il peer giusto può decifrare il
toId, riconoscersi e rispondere. - Ogni altro dispositivo sulla LAN vede solo testo cifrato — non può enumerare
quali
clientIdil mittente sta cercando di raggiungere.
È una piccola ma reale proprietà di privacy: gli osservatori passivi sulla LAN non possono costruire un grafo di chi è paired con chi.
Flag Aware nella risposta
Il DISCOVER_REPLY porta awareSupported e awareRunning. Questi non sono
persistiti nel database — sono memorizzati in memoria in PeerCacher e
aggiornati a ogni risposta (e anche dal serviceData BLE scan-response). Il
livello di trasporto li consulta per decidere se tentare un link Wi-Fi Aware o
saltare direttamente al BLE.
Sequenza di pairing (percorso felice) {#pairing-sequence-happy-path}
Il flusso end-to-end quando entrambi i dispositivi sono sulla stessa LAN e l'utente accetta il pairing:
Perché entrambe le parti memorizzano il peer indipendentemente
Si noti che sia l'initiator (passo 9) sia il responder (passo 7) chiamano
PairingPeerStore.save(...) per l'altro dispositivo. Questo è intenzionale:
ogni dispositivo finisce con una riga DPeer keyed dal clientId dell'altro,
contenente la propria copia della chiave ChaCha20 condivisa e la chiave pubblica
Ed25519 dell'altro. Non c'è registro centrale — il pairing è simmetrico e
autocontenuto.
Perché il responder calcola prima la chiave
La acceptPairingRequest del responder calcola la chiave condivisa
immediatamente all'accettazione e la persiste. Questo significa che il responder
può iniziare a ricevere traffico cifrato prima che la risposta torni
indietro all'initiator. Se la risposta viene persa in transito, il responder è
comunque paired — solo l'initiator deve riprovare.
Dettagli dello scambio di chiavi {#key-exchange-details}
Il nucleo crittografico del pairing è un accordo di chiavi X25519-style ECDH standard, ma con una firma Ed25519 sovrapposta per autenticarlo.
Cosa protegge realmente la firma
Il payload firmato (toSignatureData()) è una concatenazione canonica dei campi
stabili della richiesta: fromId, fromName, port, deviceType,
ecdhPublicKey, signaturePublicKey, timestamp e ips. Firmando
l'ecdhPublicKey insieme alla signaturePublicKey di lungo termine, il
protocollo vincola la chiave effimera all'identità del dispositivo. Un
attaccante non può sostituire la propria chiave pubblica ECDH in transito senza
invalidare la firma — e non può falsificare la firma senza controllare la
chiave Ed25519 di lungo termine.
È questo a sconfiggere un man-in-the-middle: anche se l'attaccante ritrasmette ogni pacchetto tra i due dispositivi, non può leggere il traffico cifrato (perché non ha la chiave privata ECDH di nessuna delle due parti) e non può sostituire le proprie chiavi ECDH (perché le firme si romperebbero).
Flusso Accept/Decline del responder {#responder-acceptdecline-flow}
Il lato responder mostra un dialog UI quando arriva un PAIR_REQUEST. L'utente
può accettare o rifiutare.
Perché il responder genera PairingSuccessEvent immediatamente all'accettazione
La acceptPairingRequest del responder chiama PairingPeerStore.save(...) e
genera PairingSuccessEvent prima di inviare la risposta. Questo è
deliberato: se la risposta non raggiunge mai l'initiator (glitch di rete), il
responder è comunque paired — la volta successiva che l'initiator prova il
pairing, la riga DPeer già esistente del responder verrà raccolta dal sistema
di presenza. L'initiator semplicemente riprova; il responder non deve
ri-confermare.
Flusso Cancel {#cancel-flow}
Entrambe le parti possono annullare un pairing in corso.
Si noti che DPairingCancel è inviato solo su LAN unicast (l'initiator ha
già l'IP del responder dalla fase di discovery), mentre la risposta di declino
è inviata su sia LAN sia BLE perché il responder non può sapere su quale
trasporto l'initiator sia raggiungibile.
Consegna su doppio canale (LAN + BLE) {#dual-channel-delivery-lan--ble}
Quando il responder invia la DPairingResponse, lo fa su sia LAN sia BLE
simultaneamente. L'initiator accetta la prima copia e scarta silenziosamente
il duplicato.
Perché esiste BlePairingSessionStore
Quando un PAIR_REQUEST arriva via BLE, il responder non ha un IP LAN per
l'initiator — solo il suo indirizzo MAC BLE. BlePairingSessionStore mappa
peerId → MAC così la risposta può essere instradata di nuovo via BLE se
necessario. È una piccola mappa effimera in memoria, popolata solo per le
richieste instradate via BLE e svuotata una volta inviata la risposta.
Storage di sessione e peer {#session--peer-storage}
Due store partecipano al pairing, con cicli di vita molto diversi:
Perché clientId è l'unico identificatore persistito
Android randomizza l'indirizzo MAC BLE a ogni connessione, quindi memorizzarlo
sarebbe inutile. Il clientId è derivato dal materiale chiave Ed25519 di lungo
termine del dispositivo, quindi è:
- Stabile attraverso le reinstallazioni dell'app (la chiave è nel keystore di piattaforma).
- Auto-autenticante — chiunque rivendichi un
clientIddeve dimostrare di possedere la corrispondente chiave privata Ed25519 (verificata su ogni messaggio firmato). - Privacy-preserving — solo un prefisso SHA-256 di 8 byte (
shortId) è mai trasmesso via BLE per il discovery; ilclientIdcompleto è rivelato solo ai dispositivi con cui si fa davvero pairing.
Proprietà di sicurezza {#security-properties}
| Proprietà | Come viene ottenuta |
|---|---|
| Confidentiality | Tutto il trasporto è cifrato con ChaCha20 usando la chiave condivisa derivata da ECDH. La chiave non lascia mai i due dispositivi dopo il pairing. |
| Authentication | Ogni messaggio firmato (richiesta/risposta di pairing, createChatItem di chat, invite/update/kick dei canali) è verificato con Ed25519 contro la public_key memorizzata del mittente. |
| Integrity | Le firme Ed25519 coprono l'intero corpo della richiesta; qualsiasi manomissione invalida la firma. |
| Replay resistance | ±5 min timestamp window (applicata da PeerChatParser e PairingSecurity). ChatMessageReceiver.seenSignatures deduplica entro la finestra. |
| Man-in-the-middle resistance | La chiave pubblica ECDH effimera è firmata insieme alla chiave pubblica Ed25519 di lungo termine. Un MITM non può sostituire la propria chiave ECDH senza rompere la firma. |
| Forward secrecy (limitata) | Le coppie di chiavi ECDH sono effimere per ogni sessione di pairing. Compromettere la chiave Ed25519 di lungo termine in seguito non decifra il traffico passato (è necessaria anche la chiave condivisa — ma se sia le chiavi private ECDH sia la DPeer.key memorizzata vengono cancellate, le catture passate non possono essere decifrate). |
| Denial-of-service resistance | onDatagram avvolge ogni messaggio in try/catch così un pacchetto malformato non può uccidere il receiver di discovery. PeerCircuitBreaker salta un trasporto instabile per 30 s dopo 2 fallimenti. |
| Privacy (discovery direzionato) | LANDiscoverManager.discoverSpecificDevice cifra il clientId di destinazione con la chiave del peer — gli osservatori passivi sulla LAN non possono enumerare chi è paired con chi. |
| Identity stability | clientId è derivato dal materiale chiave Ed25519 di lungo termine nel keystore di piattaforma — stabile attraverso le reinstallazioni, auto-autenticante e non legato a un numero di telefono o a un'email. |
Contro cosa il pairing NON difende
- Compromissione fisica del dispositivo. Se un attaccante ottiene root su un
dispositivo paired, può leggere la chiave condivisa dal database e
impersonare quel peer. Non c'è applicazione hardware-backed key store per la
chiave di trasporto condivisa (solo per la chiave di firma Ed25519, via
SignatureHelper). - Attacchi relay attivi. Un attaccante che possa ritrasmettere simultaneamente traffico BLE e LAN tra due dispositivi che pensano di stare facendo pairing tra loro potrebbe teoricamente posizionarsi in mezzo — ma la firma Ed25519 sulla chiave pubblica ECDH significa che non può leggere il traffico, solo ritrasmetterlo. È lo stesso compromesso del Bluetooth pairing senza confronto numerico.
- Blocco a livello di rete. Un firewall può bloccare UDP multicast, il BLE può essere disturbato e Wi-Fi Aware può essere non disponibile. Il sistema degrada gracefully (BLE è il fallback garantito per i peer paired) ma non può aggirare una rete attivamente ostile.
Riepilogo della macchina a stati {#state-machine-recap}
Letture aggiuntive
- Chat Architecture — a cosa serve la chiave condivisa: send/receive della peer chat, fan-out dei canali, presenza, download dei file.
apitest/groups/discovery.sh— piano di test eseguibile che esercita la superficie API di discovery e pairing end-to-end.