Sumário
- Por que o Pareamento Existe
- Modelo de Confiança e Criptografia
- Mapa de Componentes
- Fase de Descoberta
- Sequência de Pareamento (Caminho Feliz)
- Detalhes da Troca de Chaves
- Fluxo de Aceitar/Recusar do Responder
- Fluxo de Cancelamento
- Entrega em Canal Duplo (LAN + BLE)
- Sessão e Armazenamento de Pares
- Propriedades de Segurança
- Recapitulação da Máquina de Estados
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:
- "Quem é você?" — todo dispositivo gera um
clientIdestá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. - "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.
Modelo de Confiança e Criptografia {#trust-model--cryptography}
O pareamento usa duas primitivas criptográficas independentes:
| Primitiva | Propósito | Ciclo 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:
- Um humano tocando em Aceitar no dispositivo responder (o usuário está afirmando "sim, é com este dispositivo que quero parear").
- 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).
- 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/):
Locais dos arquivos
| Componente | Caminho (sob 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 de Descoberta {#discovery-phase}
Antes que o pareamento possa acontecer, os dispositivos precisam se encontrar.
O LANDiscoverManager roda continuamente assim que o app inicia:
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:
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.
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.
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.
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.
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:
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
clientIdprecisa 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; oclientIdcompleto só é revelado para dispositivos com os quais você efetivamente pareia.
Propriedades de Segurança {#security-properties}
| Propriedade | Como é alcançada |
|---|---|
| Confidencialidade | Todo transporte é criptografado com ChaCha20 usando a chave compartilhada derivada via ECDH. A chave nunca sai dos dois dispositivos após o pareamento. |
| Autenticação | Toda 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. |
| Integridade | Assinaturas Ed25519 cobrem o corpo completo da requisição; qualquer adulteração invalida a assinatura. |
| Resistência a replay | Janela de timestamp de ±5 min (imposta por PeerChatParser e PairingSecurity). ChatMessageReceiver.seenSignatures deduplica dentro da janela. |
| Resistência a man-in-the-middle | A 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ço | onDatagram 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 identidade | clientId é 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}
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.