Tabla de contenidos
- Por qué existe el emparejamiento
- Modelo de confianza y criptografía
- Mapa de componentes
- Fase de descubrimiento
- Secuencia de emparejamiento (camino feliz)
- Detalles del intercambio de claves
- Flujo de aceptación/rechazo del respondedor
- Flujo de cancelación
- Entrega de doble canal (LAN + BLE)
- Almacenamiento de sesión y par
- Propiedades de seguridad
- Resumen de la máquina de estados
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:
- «¿Quién es usted?» — cada dispositivo genera un
clientIdestable 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. - «¿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.
Modelo de confianza y criptografía {#trust-model--cryptography}
El emparejamiento utiliza dos primitivas criptográficas independientes:
| Primitiva | Propósito | Ciclo 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:
- Un humano que pulsa Aceptar en el dispositivo respondedor (el usuario está afirmando «sí, este es el dispositivo con el que quiero emparejarme»).
- 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).
- 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/):
Ubicaciones de archivos
| Componente | Ruta (bajo 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 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:
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:
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.
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.
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.
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.
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:
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
clientIddebe 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; elclientIdcompleto solo se revela a dispositivos con los que realmente se empareja.
Propiedades de seguridad {#security-properties}
| Propiedad | Cómo se logra |
|---|---|
| Confidencialidad | Todo 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ón | Cada 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. |
| Integridad | Las firmas Ed25519 cubren el cuerpo completo de la solicitud; cualquier manipulación invalida la firma. |
| Resistencia a reproducción | Ventana de marca de tiempo de ±5 min (aplicada por PeerChatParser y PairingSecurity). ChatMessageReceiver.seenSignatures desduplica dentro de la ventana. |
| Resistencia a man-in-the-middle | La 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 servicio | onDatagram 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 identidad | clientId 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}
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.