Volver al blog
Security10 min read

Flujo de emparejamiento

Este artículo explica cómo dos dispositivos PlainApp establecen confianza por primera vez — cómo se descubren mutuamente, intercambian claves y llegan a la clave de transporte ChaCha20 compartida con la que posteriormente se cifra cada mensaje de chat, transferencia de archivos y ping de presencia. La arquitectura de chat y canales que consume esta clave se cubre en el artículo independiente Arquitectura de Chat.

Tabla de contenidos

Por qué existe el emparejamiento {#why-pairing-exists}

PlainApp no tiene un servidor central de cuentas. Los dispositivos deben responder por tanto a dos preguntas antes de poder comunicarse:

  1. «¿Quién es usted?» — cada dispositivo genera un clientId estable en el primer inicio (un identificador de 13 caracteres derivado de su material de clave Ed25519). Es el único identificador utilizado para enrutamiento, presencia y pertenencia a canales.
  2. «¿Puedo confiar en usted?» — sin un servidor que avale la identidad, la única forma de asegurar que un par es quien afirma ser es que un humano confirme el emparejamiento en ambos dispositivos y que el protocolo verifique las firmas criptográficas.

El emparejamiento produce un único artefacto: una fila DPeer en la base de datos con status="paired", una key ChaCha20 (el secreto compartido de transporte) y la public_key Ed25519 del par (para verificar futuras firmas de mensajes). Todos los protocolos posteriores del subsistema de chat asumen que estos dos campos existen.

Diagram 1
1

Modelo de confianza y criptografía {#trust-model--cryptography}

El emparejamiento utiliza dos primitivas criptográficas independientes:

PrimitivaPropósitoCiclo de vida
Ed25519 (firma)Autentica la solicitud y la respuesta de emparejamiento. Verifica «esto proviene realmente del dispositivo que afirma enviarlo» y vincula la marca de tiempo para evitar reproducción.La clave de firma es la clave de identidad a largo plazo del dispositivo. Su mitad pública se almacena como DPeer.public_key y luego es utilizada por PeerChatParser.decrypt para verificar cada firma de mensaje de chat.
ECDH estilo X25519 (acuerdo de claves)Produce un secreto compartido que se convierte en la clave de transporte ChaCha20. Ambos dispositivos calculan el mismo secreto sin transmitirlo jamás.Par de claves efímero generado por cada sesión de emparejamiento, descartado inmediatamente después de calcular la clave compartida. El secreto resultante de 32 bytes se almacena como DPeer.key y se reutiliza durante toda la vida del emparejamiento.

No hay PIN, ni código QR, ni código fuera de banda. La confianza se establece mediante:

  1. Un humano que pulsa Aceptar en el dispositivo respondedor (el usuario está afirmando «sí, este es el dispositivo con el que quiero emparejarme»).
  2. Ambas partes verifican la firma Ed25519 del otro en la solicitud/respuesta (lo que demuestra que el respondedor está hablando con el mismo dispositivo que inició la sesión, y viceversa).
  3. Una ventana de marca de tiempo de ±5 min en ambos mensajes (lo que evita la reproducción de un apretón de manos antiguo capturado).

La asimetría importa: una única confirmación humana sería vulnerable a un ataque man-in-the-middle (el atacante podría emparejarse con ambas partes por separado). La firma Ed25519 sobre la clave pública ECDH evita esto — el respondedor verifica que la solicitud fue firmada por la misma clave Ed25519 que inició la sesión, y viceversa, de modo que un MITM no puede sustituir transparentemente su propia clave ECDH sin controlar también la clave de firma a largo plazo.

Mapa de componentes {#component-map}

Todo el código de emparejamiento reside en el paquete discover/ (no en chat/peer/pair/):

Diagram 2
2

Ubicaciones de archivos

ComponenteRuta (bajo 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 descubrimiento {#discovery-phase}

Antes de que pueda producirse el emparejamiento, los dispositivos deben encontrarse. LANDiscoverManager se ejecuta continuamente desde que la aplicación se inicia:

Diagram 3
3

Por qué el descubrimiento dirigido está cifrado

El DISCOVER de difusión no revela nada sensible (solo fromId=clientId), así que no hay problema si cualquier dispositivo en la LAN lo ve. La variante dirigida, en cambio, se utiliza cuando un dispositivo ya conoce el clientId de otro (p. ej. están emparejados pero la IP del par ha cambiado) y quiere despertarlo. Cifrar el clientId de destino con la clave compartida del par significa que:

  • El par correcto puede descifrar el toId, reconocerse a sí mismo y responder.
  • Todos los demás dispositivos en la LAN solo ven texto cifrado — no pueden enumerar qué clientIds está intentando alcanzar el remitente.

Esta es una propiedad de privacidad pequeña pero real: los observadores pasivos de la LAN no pueden construir un grafo de quién está emparejado con quién.

Banderas Aware en la respuesta

El DISCOVER_REPLY transporta awareSupported y awareRunning. Estas no se persisten en la base de datos — se almacenan en memoria en PeerCacher y se refrescan en cada respuesta (y también desde el serviceData de la scan-response de BLE). La capa de transporte las consulta para decidir si intentar un enlace Wi-Fi Aware o saltar directamente a BLE.

Secuencia de emparejamiento (camino feliz) {#pairing-sequence-happy-path}

El flujo completo cuando ambos dispositivos están en la misma LAN y el usuario acepta el emparejamiento:

Diagram 4
4

Por qué ambas partes almacenan el par de forma independiente

Nótese que tanto el iniciador (paso 9) como el respondedor (paso 7) llaman a PairingPeerStore.save(...) para el otro dispositivo. Esto es intencional: cada dispositivo termina con una fila DPeer claveada por el clientId del otro, que contiene su propia copia de la clave ChaCha20 compartida y la clave pública Ed25519 del otro. No hay registro central — el emparejamiento es simétrico y autónomo.

Por qué el respondedor calcula la clave primero

El acceptPairingRequest del respondedor calcula la clave compartida inmediatamente tras la aceptación y la persiste. Esto significa que el respondedor puede empezar a recibir tráfico cifrado antes de que la respuesta regrese al iniciador. Si la respuesta se pierde en tránsito, el respondedor sigue emparejado — solo el iniciador necesita reintentar.

Detalles del intercambio de claves {#key-exchange-details}

El núcleo criptográfico del emparejamiento es un acuerdo de claves ECDH estilo X25519 estándar, pero con una firma Ed25519 superpuesta para autenticarlo.

Diagram 5
5

Qué protege realmente la firma

La carga útil firmada (toSignatureData()) es una concatenación canónica de los campos estables de la solicitud: fromId, fromName, port, deviceType, ecdhPublicKey, signaturePublicKey, timestamp e ips. Al firmar la ecdhPublicKey junto con la signaturePublicKey a largo plazo, el protocolo vincula la clave efímera a la identidad del dispositivo. Un atacante no puede sustituir su propia clave pública ECDH en tránsito sin invalidar la firma — y no puede falsificar la firma sin controlar la clave Ed25519 a largo plazo.

Esto es lo que derrota a un man-in-the-middle: incluso si el atacante reenvía cada paquete entre los dos dispositivos, no puede leer el tráfico cifrado (porque no tiene la clave privada ECDH de ninguna de las partes) y no puede sustituir sus propias claves ECDH (porque las firmas se romperían).

Flujo de aceptación/rechazo del respondedor {#responder-acceptdecline-flow}

El lado respondedor muestra un diálogo de UI cuando llega un PAIR_REQUEST. El usuario puede aceptar o rechazar.

Diagram 6
6

Por qué el respondedor dispara PairingSuccessEvent inmediatamente al aceptar

El acceptPairingRequest del respondedor llama a PairingPeerStore.save(...) y dispara PairingSuccessEvent antes de enviar la respuesta. Esto es deliberado: si la respuesta nunca llega al iniciador (caída de red), el respondedor sigue emparejado — la próxima vez que el iniciador intente emparejarse, la fila DPeer ya existente del respondedor será recogida por el sistema de presencia. El iniciador simplemente reintenta; el respondedor no necesita volver a confirmar.

Flujo de cancelación {#cancel-flow}

Cualquiera de las partes puede cancelar un emparejamiento en curso.

Diagram 7
7

Nótese que DPairingCancel se envía solo por unicast LAN (el iniciador ya tiene la IP del respondedor desde la fase de descubrimiento), mientras que la respuesta de rechazo se envía tanto por LAN como por BLE porque el respondedor no puede saber en qué transporte es accesible el iniciador.

Entrega de doble canal (LAN + BLE) {#dual-channel-delivery-lan--ble}

Cuando el respondedor envía el DPairingResponse, lo hace tanto por LAN como por BLE simultáneamente. El iniciador acepta la primera copia y descarta silenciosamente el duplicado.

Diagram 8
8

Por qué existe BlePairingSessionStore

Cuando un PAIR_REQUEST llega por BLE, el respondedor no tiene una IP LAN para el iniciador — solo su dirección MAC BLE. BlePairingSessionStore mapea peerId → MAC para que la respuesta pueda enrutarse de vuelta por BLE si es necesario. Es un mapa pequeño, en memoria y efímero que solo se rellena para solicitudes enrutadas por BLE y se limpia una vez enviada la respuesta.

Almacenamiento de sesión y par {#session--peer-storage}

Dos almacenes participan en el emparejamiento, con ciclos de vida muy distintos:

Diagram 9
9

Por qué clientId es el único identificador persistido

Android aleatoriza la dirección MAC BLE en cada conexión, así que almacenarla sería inútil. El clientId se deriva del material de clave Ed25519 a largo plazo del dispositivo, por lo que es:

  • Estable entre reinstalaciones de la aplicación (la clave está en el keystore de la plataforma).
  • Autenticable por sí mismo — cualquiera que afirme un clientId debe demostrar que posee la clave privada Ed25519 correspondiente (verificado en cada mensaje firmado).
  • Preservador de privacidad — solo un prefijo SHA-256 de 8 bytes (shortId) se difunde por BLE para el descubrimiento; el clientId completo solo se revela a dispositivos con los que realmente se empareja.

Propiedades de seguridad {#security-properties}

PropiedadCómo se logra
ConfidencialidadTodo el transporte se cifra con ChaCha20 usando la clave compartida derivada de ECDH. La clave nunca sale de los dos dispositivos tras el emparejamiento.
AutenticaciónCada mensaje firmado (solicitud/respuesta de emparejamiento, createChatItem de chat, invite/update/kick de canal) se verifica con Ed25519 contra la public_key almacenada del remitente.
IntegridadLas firmas Ed25519 cubren el cuerpo completo de la solicitud; cualquier manipulación invalida la firma.
Resistencia a reproducciónVentana de marca de tiempo de ±5 min (aplicada por PeerChatParser y PairingSecurity). ChatMessageReceiver.seenSignatures desduplica dentro de la ventana.
Resistencia a man-in-the-middleLa clave pública ECDH efímera se firma junto con la clave pública Ed25519 a largo plazo. Un MITM no puede sustituir su propia clave ECDH sin romper la firma.
Secreto hacia adelante (limitado)Los pares de claves ECDH son efímeros por sesión de emparejamiento. Comprometer la clave Ed25519 a largo plazo posteriormente no descifra el tráfico pasado (la clave compartida sigue siendo necesaria — pero si tanto las claves privadas ECDH como el DPeer.key almacenado se borran, las capturas pasadas no pueden descifrarse).
Resistencia a denegación de servicioonDatagram envuelve cada mensaje en try/catch para que un paquete malformado no pueda matar al receptor de descubrimiento. PeerCircuitBreaker omite un transporte inestable durante 30 s tras 2 fallos.
Privacidad (descubrimiento dirigido)LANDiscoverManager.discoverSpecificDevice cifra el clientId de destino con la clave del par — los observadores pasivos de la LAN no pueden enumerar quién está emparejado con quién.
Estabilidad de identidadclientId se deriva del material de clave Ed25519 a largo plazo en el keystore de la plataforma — estable entre reinstalaciones, autenticable por sí mismo y no vinculado a un número de teléfono ni correo electrónico.

Contra qué NO protege el emparejamiento

  • Compromiso físico del dispositivo. Si un atacante obtiene root en un dispositivo emparejado, puede leer la clave compartida de la base de datos e supantar a ese par. No hay aplicación de keystore respaldado por hardware para la clave compartida de transporte (solo para la clave de firma Ed25519, vía SignatureHelper).
  • Ataques de retransmisión activos. Un atacante que pueda retransmitir simultáneamente tráfico BLE y LAN entre dos dispositivos que creen estar emparejándose entre sí podría posicionarse teóricamente en el medio — pero la firma Ed25519 sobre la clave pública ECDH significa que no puede leer el tráfico, solo retransmitirlo. Es la misma contrapartida que el emparejamiento Bluetooth sin comparación numérica.
  • Bloqueo a nivel de red. Un firewall puede bloquear UDP multicast, BLE puede ser interferido y Wi-Fi Aware puede no estar disponible. El sistema se degrada con elegancia (BLE es el fallback garantizado para pares emparejados) pero no puede eludir una red activamente hostil.

Resumen de la máquina de estados {#state-machine-recap}

Diagram 10
10

Lecturas adicionales

  • Arquitectura de Chat — para qué se utiliza la clave compartida: envío/recepción de chat entre pares, distribución de canal, presencia, descargas de archivos.
  • apitest/groups/discovery.sh — plan de pruebas ejecutable que ejercita la superficie de la API de descubrimiento y emparejamiento de punta a punta.