Torna al blog
Security10 min read

Flusso di Pairing

Questo articolo spiega come due dispositivi PlainApp stabiliscano fiducia per la prima volta — come si scoprono a vicenda, scambiano chiavi e arrivano alla chiave di trasporto ChaCha20 condivisa con cui vengono cifrati ogni messaggio di chat, trasferimento di file e ping di presenza. L'architettura chat e canali che consuma questa chiave è trattata nel separato articolo Chat Architecture.

Sommario

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:

  1. «Chi sei?» — ogni dispositivo genera un clientId stabile al primo avvio (un id di 13 caratteri derivato dal materiale chiave Ed25519). È l'unico identificatore usato per routing, presenza e appartenenza ai canali.
  2. «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.

Diagram 1
1

Modello di fiducia e crittografia {#trust-model--cryptography}

Il pairing usa due primitive crittografiche indipendenti:

PrimitivaScopoCiclo 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:

  1. Una persona che tocca Accept sul dispositivo responder (l'utente sta asserendo «sì, questo è il dispositivo con cui voglio fare il pairing»).
  2. 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).
  3. 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/):

Diagram 2
2

Posizioni dei file

ComponentePercorso (sotto 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

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:

Diagram 3
3

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 clientId il 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:

Diagram 4
4

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.

Diagram 5
5

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.

Diagram 6
6

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.

Diagram 7
7

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.

Diagram 8
8

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:

Diagram 9
9

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 clientId deve 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; il clientId completo è rivelato solo ai dispositivi con cui si fa davvero pairing.

Proprietà di sicurezza {#security-properties}

ProprietàCome viene ottenuta
ConfidentialityTutto il trasporto è cifrato con ChaCha20 usando la chiave condivisa derivata da ECDH. La chiave non lascia mai i due dispositivi dopo il pairing.
AuthenticationOgni messaggio firmato (richiesta/risposta di pairing, createChatItem di chat, invite/update/kick dei canali) è verificato con Ed25519 contro la public_key memorizzata del mittente.
IntegrityLe 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 resistanceLa 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 resistanceonDatagram 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 stabilityclientId è 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}

Diagram 10
10

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.