Voltar ao blog
Security10 min read

Fluxo de Pareamento

Este artigo explica como dois dispositivos PlainApp estabelecem confiança pela primeira vez — como eles se descobrem, trocam chaves e chegam à chave de transporte ChaCha20 compartilhada com a qual toda mensagem de chat, transferência de arquivo e ping de presença é criptografada em seguida. A arquitetura de chat e canais que consome essa chave é abordada no artigo separado Arquitetura de Chat.

Sumário

Por que o Pareamento Existe {#why-pairing-exists}

O PlainApp não possui servidor central de contas. Os dispositivos precisam, portanto, responder a duas perguntas antes de conseguir se comunicar:

  1. "Quem é você?" — todo dispositivo gera um clientId estável na primeira inicialização (um id de 13 caracteres derivado do material da sua chave Ed25519). Esse é o único identificador usado para roteamento, presença e associação a canais.
  2. "Posso confiar em você?" — sem um servidor que ateste a identidade, a única forma de ter certeza de que um par é quem afirma ser é um humano confirmar o pareamento em ambos os dispositivos e o protocolo verificar as assinaturas criptográficas.

O pareamento produz um único artefato: uma linha DPeer no banco de dados com status="paired", uma key ChaCha20 (o segredo de transporte compartilhado) e a public_key Ed25519 do par (para verificar assinaturas futuras de mensagens). Todo protocolo posterior no subsistema de chat assume que esses dois campos existem.

Diagram 1
1

Modelo de Confiança e Criptografia {#trust-model--cryptography}

O pareamento usa duas primitivas criptográficas independentes:

PrimitivaPropósitoCiclo de vida
Ed25519 (assinatura)Autenticar a requisição e a resposta de pareamento. Verifica "isso realmente veio do dispositivo que afirma tê-lo enviado" e vincula o timestamp para evitar replay.A chave de assinatura é a chave de identidade de longo prazo do dispositivo. Sua metade pública é armazenada como DPeer.public_key e depois usada por PeerChatParser.decrypt para verificar todas as assinaturas de mensagens de chat.
X25519-style ECDH (acordo de chaves)Produzir um segredo compartilhado que se torna a chave de transporte ChaCha20. Os dois dispositivos computam o mesmo segredo sem nunca transmiti-lo.Par de chaves efêmero gerado por sessão de pareamento, descartado imediatamente após a chave compartilhada ser computada. O segredo de 32 bytes resultante é armazenado como DPeer.key e reutilizado por toda a vida do pareamento.

Não há PIN, nem QR code, nem código fora de banda. A confiança é estabelecida por:

  1. Um humano tocando em Aceitar no dispositivo responder (o usuário está afirmando "sim, é com este dispositivo que quero parear").
  2. Ambos os lados verificando a assinatura Ed25519 um do outro na requisição/resposta (provando que o responder está conversando com o mesmo dispositivo que iniciou a sessão e vice-versa).
  3. Uma janela de timestamp de ±5 min em ambas as mensagens (evitando replay de um handshake antigo capturado).

A assimetria importa: uma única confirmação humana seria vulnerável a um man-in-the-middle (o atacante poderia parear com ambos os lados separadamente). A assinatura Ed25519 sobre a chave pública ECDH previne isso — o responder verifica que a requisição foi assinada pela mesma chave Ed25519 que iniciou a sessão, e vice-versa, de modo que um MITM não consegue substituir transparentemente sua própria chave ECDH sem também controlar a chave de assinatura de longo prazo.

Mapa de Componentes {#component-map}

Todo o código de pareamento vive no pacote discover/ (não em chat/peer/pair/):

Diagram 2
2

Locais dos arquivos

ComponenteCaminho (sob 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 de Descoberta {#discovery-phase}

Antes que o pareamento possa acontecer, os dispositivos precisam se encontrar. O LANDiscoverManager roda continuamente assim que o app inicia:

Diagram 3
3

Por que a descoberta direcionada é criptografada

O DISCOVER de broadcast não revela nada sensível (apenas fromId=clientId), então não há problema se qualquer dispositivo na LAN o veja. A variante direcionada, no entanto, é usada quando um dispositivo já conhece o clientId de outro (por exemplo, já estão pareados, mas o IP do par mudou) e quer acordá-lo. Criptografar o clientId alvo com a chave compartilhada do par significa que:

  • O par certo consegue descriptografar o toId, reconhecer a si mesmo e responder.
  • Todos os outros dispositivos na LAN veem apenas texto cifrado — eles não conseguem enumerar quais clientIds o remetente está tentando contatar.

Essa é uma propriedade de privacidade pequena, mas real: observadores passivos da LAN não conseguem construir um grafo de quem está pareado com quem.

Flags Aware na resposta

O DISCOVER_REPLY carrega awareSupported e awareRunning. Eles não são persistidos no banco de dados — são armazenados em memória no PeerCacher e atualizados a cada resposta (e também a partir do serviceData da scan-response do BLE). A camada de transporte os consulta para decidir se deve tentar um link Wi-Fi Aware ou pular direto para o BLE.

Sequência de Pareamento (Caminho Feliz) {#pairing-sequence-happy-path}

O fluxo de ponta a ponta quando ambos os dispositivos estão na mesma LAN e o usuário aceita o pareamento:

Diagram 4
4

Por que ambos os lados armazenam o par independentemente

Note que tanto o initiator (passo 9) quanto o responder (passo 7) chamam PairingPeerStore.save(...) para o outro dispositivo. Isso é intencional: cada dispositivo termina com uma linha DPeer chaveada pelo clientId do outro, contendo sua própria cópia da chave ChaCha20 compartilhada e a chave pública Ed25519 do outro. Não há registro central — o pareamento é simétrico e autocontido.

Por que o responder computa a chave primeiro

O acceptPairingRequest do responder computa a chave compartilhada imediatamente após a aceitação e a persiste. Isso significa que o responder pode começar a receber tráfego criptografado antes que a resposta chegue de volta ao initiator. Se a resposta se perder no caminho, o responder ainda assim estará pareado — apenas o initiator precisa tentar de novo.

Detalhes da Troca de Chaves {#key-exchange-details}

O núcleo criptográfico do pareamento é um acordo de chaves X25519-style ECDH padrão, mas com uma assinatura Ed25519 sobreposta para autenticá-lo.

Diagram 5
5

O que a assinatura realmente protege

O payload assinado (toSignatureData()) é uma concatenação canônica dos campos estáveis da requisição: fromId, fromName, port, deviceType, ecdhPublicKey, signaturePublicKey, timestamp e ips. Ao assinar o ecdhPublicKey junto com a signaturePublicKey de longo prazo, o protocolo vincula a chave efêmera à identidade do dispositivo. Um atacante não consegue substituir sua própria chave pública ECDH em trânsito sem invalidar a assinatura — e não consegue forjar a assinatura sem controlar a chave Ed25519 de longo prazo.

É isso que derrota um man-in-the-middle: mesmo que o atacante retransmita todos os pacotes entre os dois dispositivos, ele não consegue ler o tráfego criptografado (porque não tem a chave privada ECDH de nenhum dos lados) e não consegue substituir por suas próprias chaves ECDH (porque as assinaturas quebrariam).

Fluxo de Aceitar/Recusar do Responder {#responder-acceptdecline-flow}

O lado responder exibe um diálogo na UI quando um PAIR_REQUEST chega. O usuário pode aceitar ou recusar.

Diagram 6
6

Por que o responder dispara PairingSuccessEvent imediatamente ao aceitar

O acceptPairingRequest do responder chama PairingPeerStore.save(...) e dispara o PairingSuccessEvent antes de enviar a resposta. Isso é deliberado: se a resposta nunca chegar ao initiator (falha de rede), o responder ainda assim estará pareado — na próxima vez que o initiator tentar parear, a linha DPeer já existente do responder será captada pelo sistema de presença. O initiator simplesmente tenta de novo; o responder não precisa confirmar novamente.

Fluxo de Cancelamento {#cancel-flow}

Qualquer lado pode cancelar um pareamento em andamento.

Diagram 7
7

Note que o DPairingCancel é enviado apenas via LAN unicast (o initiator já tem o IP do responder a partir da fase de descoberta), enquanto a resposta de recusa é enviada tanto via LAN quanto via BLE porque o responder não sabe com certeza em qual transporte o initiator está alcançável.

Entrega em Canal Duplo (LAN + BLE) {#dual-channel-delivery-lan--ble}

Quando o responder envia o DPairingResponse, ele o faz simultaneamente via LAN e via BLE. O initiator aceita a primeira cópia e descarta silenciosamente a duplicata.

Diagram 8
8

Por que o BlePairingSessionStore existe

Quando um PAIR_REQUEST chega via BLE, o responder não tem um IP de LAN para o initiator — apenas o endereço MAC do BLE. O BlePairingSessionStore mapeia peerId → MAC para que a resposta possa ser roteada de volta via BLE, se necessário. É um mapa pequeno, em memória, efêmero, populado apenas para requisições roteadas via BLE e limpo assim que a resposta é enviada.

Sessão e Armazenamento de Pares {#session--peer-storage}

Dois repositórios participam do pareamento, com ciclos de vida muito diferentes:

Diagram 9
9

Por que o clientId é o único identificador persistido

O Android randomiza o endereço MAC do BLE a cada conexão, então armazená-lo seria inútil. O clientId é derivado do material da chave Ed25519 de longo prazo do dispositivo, então ele é:

  • Estável entre reinstalações do app (a chave está no keystore da plataforma).
  • Autenticável por si só — qualquer um que afirme um clientId precisa provar que possui a chave privada Ed25519 correspondente (verificado em cada mensagem assinada).
  • Preserva a privacidade — apenas um prefixo SHA-256 de 8 bytes (shortId) é broadcastado via BLE para descoberta; o clientId completo só é revelado para dispositivos com os quais você efetivamente pareia.

Propriedades de Segurança {#security-properties}

PropriedadeComo é alcançada
ConfidencialidadeTodo transporte é criptografado com ChaCha20 usando a chave compartilhada derivada via ECDH. A chave nunca sai dos dois dispositivos após o pareamento.
AutenticaçãoToda mensagem assinada (requisição/resposta de pareamento, createChatItem de chat, invite/update/kick de canal) é verificada por Ed25519 contra a public_key armazenada do remetente.
IntegridadeAssinaturas Ed25519 cobrem o corpo completo da requisição; qualquer adulteração invalida a assinatura.
Resistência a replayJanela de timestamp de ±5 min (imposta por PeerChatParser e PairingSecurity). ChatMessageReceiver.seenSignatures deduplica dentro da janela.
Resistência a man-in-the-middleA chave pública ECDH efêmera é assinada junto com a chave pública Ed25519 de longo prazo. Um MITM não consegue substituir sua própria chave ECDH sem quebrar a assinatura.
Sigilo futuro (limitado)Os pares de chaves ECDH são efêmeros por sessão de pareamento. Comprometer a chave Ed25519 de longo prazo depois não descriptografa tráfego passado (a chave compartilhada também ainda é necessária — mas se tanto as chaves privadas ECDH quanto o DPeer.key armazenado forem apagados, capturas passadas não podem ser descriptografadas).
Resistência a negação de serviçoonDatagram envolve cada mensagem em try/catch para que um pacote malformado não mate o receiver de descoberta. PeerCircuitBreaker pula um transporte instável por 30 s após 2 falhas.
Privacidade (descoberta direcionada)LANDiscoverManager.discoverSpecificDevice criptografa o clientId alvo com a chave do par — observadores passivos da LAN não conseguem enumerar quem está pareado com quem.
Estabilidade de identidadeclientId é derivado do material da chave Ed25519 de longo prazo no keystore da plataforma — estável entre reinstalações, autenticável por si só e não vinculado a número de telefone ou e-mail.

Contra o que o pareamento NÃO defende

  • Comprometimento físico do dispositivo. Se um atacante obtiver root em um dispositivo pareado, ele pode ler a chave compartilhada do banco de dados e se passar por esse par. Não há imposição de keystore com suporte de hardware para a chave de transporte compartilhada (apenas para a chave de assinatura Ed25519, via SignatureHelper).
  • Ataques de relay ativo. Um atacante que consiga retransmitir simultaneamente tráfego BLE e LAN entre dois dispositivos que acham que estão pareando um com o outro poderia, em teoria, se posicionar no meio — mas a assinatura Ed25519 sobre a chave pública ECDH significa que ele não consegue ler o tráfego, apenas retransmiti-lo. É a mesma troca do pareamento Bluetooth sem comparação numérica.
  • Bloqueio em nível de rede. Um firewall pode bloquear UDP multicast, o BLE pode ser jammed e o Wi-Fi Aware pode estar indisponível. O sistema degrada graciosamente (BLE é o fallback garantido para pares pareados), mas não consegue contornar uma rede ativamente hostil.

Recapitulação da Máquina de Estados {#state-machine-recap}

Diagram 10
10

Leitura Adicional

  • Arquitetura de Chat — para que a chave compartilhada é usada: envio/recebimento de chat entre pares, fan-out de canais, presença, downloads de arquivos.
  • apitest/groups/discovery.sh — plano de testes executável que exercita a superfície da API de descoberta e pareamento de ponta a ponta.