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).
ParteVêNão vê
Backend / DBtransferId, ciphertext, iv, saltPIN, par de chaves em texto plano
Atacante de redeMesmo que o backendPIN
Outro appNadaPIN

Parâmetros Criptográficos

PropósitoAlgoritmoParâmetros
PIN → KEKPBKDF2-SHA256200.000 iterações, salt aleatório de 16 bytes
Criptografia do payloadAES-256-GCMChave 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

  1. Cliente de origem (PWA) lê o par de chaves do IndexedDB.
  2. Payload é JSON → deflate → AES-256-GCM(KEK) com KEK derivada do PIN.
  3. PIN é gerado localmente, nunca enviado ao servidor.
  4. Payload criptografado é enviado: POST /api/key-transfer.
  5. Backend retorna transferId e expiresAt.
  6. PWA mostra PIN + link da loja para o usuário.
  7. Usuário instala e abre o app, digita o PIN.
  8. App busca: GET /api/key-transfer/{transferId}.
  9. App deriva KEK, descriptografa, valida, armazena em secure storage.
  10. 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çãoEventoPayload
destino → servidorrequest_key_transfer{ token } — token de deeplink da conversa
servidor → origemshow_key_transfer_dialog{ conversationToken? }
origem → servidorsend_transfer_id{ transferId }
servidor → destinoreceive_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.