Transferencia de Clave
Transfiera el par de claves E2EE del cliente desde el PWA a una app nativa en el mismo dispositivo.
Problema
La clave privada del cliente está en el IndexedDB del PWA. Cuando el usuario instala la app nativa, la app no tiene clave y generaría una nueva, haciendo que cada mensaje envuelto para la clave antigua sea indescifrable.
No hay canal directo entre el PWA y la app. El payload debe viajar a través del backend, cifrado con un secreto que el backend nunca ve.
Modelo de Seguridad
La propiedad de seguridad es separación de canales:
- El ciphertext viaja por HTTPS (el backend lo ve pero no puede descifrar).
- El PIN viaja a través de los ojos y dedos del usuario (nunca enviado al servidor).
| Parte | Ve | No ve |
|---|---|---|
| Backend / DB | transferId, ciphertext, iv, salt | PIN, par de claves en texto plano |
| Atacante de red | Igual que el backend | PIN |
| Otra app | Nada | PIN |
Parámetros Criptográficos
| Propósito | Algoritmo | Parámetros |
|---|---|---|
| PIN → KEK | PBKDF2-SHA256 | 200.000 iteraciones, salt aleatorio de 16 bytes |
| Cifrado del payload | AES-256-GCM | Clave 256-bit, IV 96-bit, tag 128-bit |
Protección del PIN
Un PIN de 6 dígitos tiene ~20 bits de entropía. Es seguro porque:
- TTL corto (5 minutos) en la entrada de Redis.
- Límite de intentos (5 intentos, luego la entrada se destruye).
- Recuperación única (GET transiciona la entrada a
RETRIEVED).
Flujo
- El cliente de origen (PWA) lee el par de claves del IndexedDB.
- El payload es JSON → deflate → AES-256-GCM(KEK) con KEK derivada del PIN.
- El PIN se genera localmente, nunca se envía al servidor.
- El payload cifrado se sube:
POST /api/key-transfer. - El backend devuelve
transferIdyexpiresAt. - El PWA muestra PIN + enlace de la tienda al usuario.
- El usuario instala y abre la app, ingresa el PIN.
- La app obtiene:
GET /api/key-transfer/{transferId}. - La app deriva KEK, descifra, valida, almacena en secure storage.
- La app confirma:
POST /api/key-transfer/{transferId}/confirm.
Transferencia Rápida (en la conversación)
Cuando el cliente abre una conversación en un dispositivo nuevo y no puede descifrar el historial, la página de chat ofrece importar la clave desde otro dispositivo. El transferId se intercambia por la conexión Socket.io compartida en lugar de un enlace, así el usuario solo lee el PIN en una pantalla y lo escribe en la otra.
El dispositivo de origen no necesita tener la misma conversación abierta — solo la app abierta y autenticada para el mismo par de claves. La lista de conversaciones es suficiente.
Los eventos se enrutan por la sala de la clave del cliente (customer:{customerPublicKey}), así un dispositivo solo recibe eventos de su propio par de claves.
| Dirección | Evento | Payload |
|---|---|---|
| destino → servidor | request_key_transfer | { token } — token de deeplink de la conversación |
| servidor → origen | show_key_transfer_dialog | { conversationToken? } |
| origen → servidor | send_transfer_id | { transferId } |
| servidor → destino | receive_transfer_id | { transferId, conversationToken? } |
send_transfer_id lleva solo el transferId: el backend resuelve la conversación a la que está vinculada la transferencia y enruta el evento a la sala de clave de esa conversación. El vínculo se define en la creación a partir del conversationToken opcional en POST /api/key-transfer, que el backend resuelve al id de la conversación en el servidor — el cliente no puede reclamar un id de conversación arbitrario. El token se devuelve en la recuperación para que el destino pueda volver a la conversación tras importar la clave.
Dos propiedades hacen que esto funcione y son fáciles de romper:
- El dispositivo solicitante se une a la sala de clave. Es el que no puede descifrar, así que normalmente no tiene clave registrada y
authenticate lo puso en la sala de la conversación. Sin la unión explícita, la respuesta nunca puede llegarle. - Un dispositivo que no puede descifrar no registra su clave. Registrar una clave nueva sobrescribiría la que el dispositivo de origen aún posee, dejando la conversación inalcanzable para él y rompiendo la transferencia.
Formato del Payload
Payload de la clave {
"v": 1,
"keyId": "a3f9...",
"publicKeySpki": "MIIBIjANBgkq...",
"privateKeyPkcs8": "MIIEvQIBADANBgkq..."
}
Envelope en el Backend
Envelope almacenado {
"ciphertext": "8f3a...",
"iv": "9c2b...",
"salt": "1d4e...",
"kdf": { "alg": "PBKDF2-SHA256", "iterations": 200000 },
"conversationToken": "d3f1..."
}
conversationToken es opcional y solo está presente cuando la transferencia se inició desde una pantalla de conversación. Es el token de deeplink de la conversación — el mismo valor ya presente en la URL de la conversación, por lo que no es un secreto.