Transferência de Chave
Transfira o par de chaves E2EE do cliente do PWA para um app nativo no mesmo dispositivo.
Problema
A chave privada do cliente fica no IndexedDB do PWA. Quando o usuário instala o app nativo, o app não tem chave e geraria uma nova, tornando toda mensagem envolvida para a chave antiga indecifrável.
Não há canal direto entre o PWA e o app. O payload deve viajar pelo backend, criptografado com um segredo que o backend nunca vê.
Modelo de Segurança
A propriedade de segurança é separação de canais:
- O ciphertext viaja por HTTPS (backend vê, mas não pode descriptografar).
- O PIN viaja pelos olhos e dedos do usuário (nunca enviado ao servidor).
| Parte | Vê | Não vê |
|---|---|---|
| Backend / DB | transferId, ciphertext, iv, salt | PIN, par de chaves em texto plano |
| Atacante de rede | Mesmo que o backend | PIN |
| Outro app | Nada | PIN |
Parâmetros Criptográficos
| Propósito | Algoritmo | Parâmetros |
|---|---|---|
| PIN → KEK | PBKDF2-SHA256 | 200.000 iterações, salt aleatório de 16 bytes |
| Criptografia do payload | AES-256-GCM | Chave 256-bit, IV 96-bit, tag 128-bit |
Proteção do PIN
Um PIN de 6 dígitos tem ~20 bits de entropia. É seguro porque:
- TTL curto (5 minutos) na entrada do Redis.
- Limite de tentativas (5 tentativas, depois a entrada é destruída).
- Recuperação única (GET transiciona a entrada para
RETRIEVED).
Fluxo
- Cliente de origem (PWA) lê o par de chaves do IndexedDB.
- Payload é JSON → deflate → AES-256-GCM(KEK) com KEK derivada do PIN.
- PIN é gerado localmente, nunca enviado ao servidor.
- Payload criptografado é enviado:
POST /api/key-transfer. - Backend retorna
transferIdeexpiresAt. - PWA mostra PIN + link da loja para o usuário.
- Usuário instala e abre o app, digita o PIN.
- App busca:
GET /api/key-transfer/{transferId}. - App deriva KEK, descriptografa, valida, armazena em secure storage.
- App confirma:
POST /api/key-transfer/{transferId}/confirm.
Transferência Rápida (na conversa)
Quando o cliente abre uma conversa em um novo dispositivo e não consegue descriptografar o histórico, a página de chat oferece importar a chave de outro dispositivo. O transferId é trocado pela conexão Socket.io compartilhada em vez de um link, então o usuário só lê o PIN em uma tela e digita na outra.
O dispositivo de origem não precisa estar com a mesma conversa aberta — apenas com o app aberto e autenticado para o mesmo par de chaves. A lista de conversas é suficiente.
Os eventos são roteados pela sala da chave do cliente (customer:{customerPublicKey}), então um dispositivo só recebe eventos do seu próprio par de chaves.
| Direção | Evento | Payload |
|---|---|---|
| destino → servidor | request_key_transfer | { token } — token de deeplink da conversa |
| servidor → origem | show_key_transfer_dialog | { conversationToken? } |
| origem → servidor | send_transfer_id | { transferId } |
| servidor → destino | receive_transfer_id | { transferId, conversationToken? } |
send_transfer_id carrega apenas o transferId: o backend resolve a conversa à qual a transferência está vinculada e roteia o evento para a sala de chave dessa conversa. O vínculo é definido na criação a partir do conversationToken opcional em POST /api/key-transfer, que o backend resolve para o id da conversa no servidor — o cliente não pode reivindicar um id de conversa arbitrário. O token é devolvido na recuperação para que o destino possa voltar à conversa após importar a chave.
Duas propriedades fazem isso funcionar e são fáceis de quebrar:
- O dispositivo solicitante entra na sala de chave. É ele que não consegue descriptografar, então normalmente não tem chave registrada e o
authenticate o colocou na sala da conversa. Sem o join explícito, a resposta nunca chega até ele. - Um dispositivo que não consegue descriptografar não registra sua chave. Registrar uma chave nova sobrescreveria a que o dispositivo de origem ainda possui, tornando a conversa inalcançável para ele e quebrando a transferência.
Formato do Payload
Payload da chave {
"v": 1,
"keyId": "a3f9...",
"publicKeySpki": "MIIBIjANBgkq...",
"privateKeyPkcs8": "MIIEvQIBADANBgkq..."
}
Envelope no Backend
Envelope armazenado {
"ciphertext": "8f3a...",
"iv": "9c2b...",
"salt": "1d4e...",
"kdf": { "alg": "PBKDF2-SHA256", "iterations": 200000 },
"conversationToken": "d3f1..."
}
conversationToken é opcional e só está presente quando a transferência foi iniciada a partir de uma tela de conversa. É o token de deeplink da conversa — o mesmo valor já presente na URL da conversa, portanto não é um segredo.