Como implementar E2EE (passo a passo)
Tutorial conceitual + prático pra implementar end-to-end encryption num chat — do zero até DMs, grupos, backup, rotação e auto-repair. Baseado na implementação real do Geek Social com Signal Protocol via libsignal-wasm e nas specs oficiais do Signal.
Este tutorial é o guia que eu queria ter tido quando comecei. Ele explica o que cada conceito é, por que existe e como aplicar, com referências ao código real do Geek Social e às specs oficiais do Signal. A página de Criptografia E2EE é a referência da arquitetura final — esta aqui é o caminho pra chegar lá.
⚠️ Não invente cripto. Em todos os passos abaixo você vai usar uma biblioteca pronta e bem auditada (libsignal). O trabalho do dev é a integração correta, não os algoritmos. Se você está pensando "vou implementar o Double Ratchet do zero", pare.
Parte 1 — Conceitos (entender antes de codar)
1.1 Por que E2EE existe?
E2EE responde a uma pergunta de threat model: a quem você confia o conteúdo das mensagens?
| Modelo | Quem lê o plaintext |
|---|---|
| Sem cripto | Server, operador, atacante MITM, atacante no DB |
| TLS only | Server, operador, atacante no DB |
| E2EE | Apenas remetente e destinatário |
E2EE transfere a confiança do server para os endpoints (devices). O server passa a ser apenas um diretório de chaves públicas + relay de ciphertext.
Implicação prática: o server não pode oferecer features que dependam de ler conteúdo (busca server-side, moderação automática por palavra-chave, sumarização IA do histórico). Decida isso antes de começar.
1.2 As propriedades de segurança que você está comprando
Antes de entrar nas primitivas, vale fixar o que você quer atingir. Cada primitiva entrega uma destas:
- Sigilo (confidentiality): ninguém além do destinatário lê a mensagem.
- Autenticação: o destinatário tem certeza de quem enviou.
- Forward secrecy (sigilo retroativo): se a chave de hoje vazar, mensagens de ontem permanecem ilegíveis. A chave de ontem foi destruída.
- Post-compromise security (segurança pós-comprometimento, ou self-healing): se o atacante invadir o device hoje e copiar o estado, ele perde o acesso depois de algumas trocas — o protocolo "se cura" sozinho injetando entropia nova.
- Deniability (negabilidade): o destinatário não consegue provar pra um terceiro que foi você que enviou. Conversa fica como "ele falou, ela falou" — não há assinatura digital de cada mensagem.
- Resistência pós-quântica: mesmo que daqui a 20 anos um computador quântico quebre a criptografia clássica, mensagens gravadas hoje continuam ilegíveis.
O Signal Protocol entrega todas essas, com a ressalva importante de que post-compromise e PQ ainda têm limitações (ver seções abaixo).
1.3 Os blocos do Signal Protocol
O Signal Protocol é uma composição de quatro primitivas. Entender cada uma resolve 80% das dúvidas de implementação.
Identidade de longo prazo (Identity Key — IK)
Um par de chaves Curve25519 que representa "quem você é" criptograficamente. Gerado uma vez por user e nunca rotacionado. O hash da chave pública é o que aparece em "verify safety number".
- Pública vai pro server (e pros peers)
- Privada nunca sai do dispositivo do user (exceto cifrada no backup)
Se a identidade for comprometida, o user vira "outra pessoa" — os peers verão IdentityChangedError.
Curve25519 em uma frase: uma curva elíptica que dá DH (Diffie-Hellman) rápido, com chaves de 32 bytes e nível de segurança equivalente a RSA-3072. É padrão moderno, sem patentes, sem números mágicos da NSA.
Signed Pre-Key (SPK)
Uma chave Curve25519 de médio prazo assinada pela identidade. Rotacionada a cada ~7 dias.
Por que existe: o handshake X3DH precisa de uma chave pública do destinatário pra fazer DH antes que ele esteja online. A SPK fica publicada no server, pré-assinada pela IK — qualquer um pode iniciar uma sessão sem precisar que o peer esteja online, e a assinatura prova que foi o próprio peer que a publicou.
Por que rotacionar: se a SPK privada vazar, mensagens cifradas pra ela ficam expostas. Rotação semanal limita o "raio de explosão" de um vazamento.
Kyber Pre-Key (PQXDH)
Mesma ideia da SPK, mas com Kyber1024 (padronizado pelo NIST como ML-KEM, FIPS 203). Não é uma chave de DH — é uma chave de KEM (Key Encapsulation Mechanism).
KEM em uma frase: primitiva onde o remetente encapsula um segredo aleatório usando a chave pública do destinatário, gerando um ciphertext de encapsulamento; só o destinatário (com a privada) consegue desencapsular e recuperar o segredo. É o equivalente assimétrico ao "compartilhar uma chave secreta", sem usar DH.
Por que existe: mesmo que daqui a 20 anos um computador quântico quebre Curve25519 via algoritmo de Shor, o segredo derivado de Kyber permanece — porque Kyber é baseado em problemas de reticulados (Module-LWE), que resistem a quânticos. Defesa contra "harvest now, decrypt later" — adversário grava ciphertext hoje pra decifrar no futuro.
O que ainda fica vulnerável: a autenticação continua clássica (assinatura Ed25519). Um atacante quântico ativo poderia forjar identidades. A própria spec do PQXDH reconhece: "não fornece autenticação quântica-segura nesta revisão". Sigilo ✓, autenticação ✗.
One-Time Pre-Keys (OTPs ou OPKs)
Chaves Curve25519 descartáveis. Cada handshake X3DH consome uma. Publique em lote (100 por vez) e faça refill quando o server reportar contagem baixa.
Por que existem: sem OTP, o handshake X3DH é deterministicamente idêntico se A iniciar 2 sessões com B usando as mesmas SPK e Kyber — perdendo forward secrecy do material inicial. A OTP injeta entropia única por sessão e é apagada imediatamente após o uso, garantindo que nem o próprio destinatário consegue rederivar a chave depois.
Fallback obrigatório: se acabarem as OTPs, o handshake ainda funciona com forward secrecy ligeiramente reduzida (depende só da rotação da SPK). Não bloqueie a primeira mensagem por falta de OTP.
X3DH (Extended Triple Diffie-Hellman)
O handshake que estabelece o segredo de sessão (SK) entre A e B. Executa quatro DHs entre as chaves disponíveis e combina os resultados via HKDF.
Os quatro DHs (com EK_A = chave efêmera gerada por A só pra este handshake):
| DH | Operação | Garante |
|---|---|---|
| DH1 | DH(IK_A, SPK_B) | Autenticação de A perante B (prova posse de IK_A) |
| DH2 | DH(EK_A, IK_B) | Autenticação de B perante A (depende de IK_B) |
| DH3 | DH(EK_A, SPK_B) | Sigilo via efêmera + chave de médio prazo |
| DH4 | DH(EK_A, OPK_B) | Sigilo extra via efêmera + chave descartável (se OTP disponível) |
A SK final é KDF(DH1 ∥ DH2 ∥ DH3 ∥ [DH4]). DH1+DH2 dão autenticação mútua; DH3+DH4 dão forward secrecy mesmo se as IKs vazarem amanhã, porque EK_A e OPK_B privadas já foram destruídas.
PQXDH = X3DH + KEM Kyber
O PQXDH adiciona um quinto componente ao handshake: A executa (CT, SS) = Kyber.Encaps(PQPK_B), gerando um ciphertext de encapsulamento CT e um segredo SS. A SK fica:
SK = KDF(DH1 ∥ DH2 ∥ DH3 ∥ [DH4] ∥ SS)A envia CT junto com a mensagem inicial; B recupera SS com Kyber.Decaps(CT, sk_Kyber_B). Da perspectiva da integração com a libsignal, é a mesma chamada — process_pre_key_bundle() já junta tudo.
Abordagem híbrida: a SK depende de ambos os mundos, clássico e pós-quântico. Se Kyber tiver uma vulnerabilidade descoberta no futuro, X25519 ainda protege. E vice-versa.
Double Ratchet — o coração do forward secrecy
Depois do handshake, a SK alimenta o Double Ratchet, que gerencia todas as chaves de mensagens daí em diante. O nome "double" vem de dois mecanismos rodando em paralelo:
Symmetric ratchet (cadeia simétrica)
Cada lado mantém duas cadeias de chaves: chain key de envio e chain key de recebimento. A cada mensagem:
(CK_próxima, MK) = KDF(CK_atual)A MK (Message Key) cifra uma mensagem só e é deletada. A CK_próxima fica pra próxima mensagem.
| Termo | Papel | Vida útil |
|---|---|---|
| Chain Key (CK) | "Adubo" — nunca cifra direto, só gera próxima CK + MK | Enquanto a cadeia estiver ativa |
| Message Key (MK) | Cifra/decifra exatamente uma mensagem | Deletada imediatamente após uso |
KDF é unidirecional: ter CK_atual não permite recuperar CK_anterior. Forward secrecy dentro da cadeia, garantida.
DH ratchet (cadeia DH)
A cada vez que a conversa "vira" (B responde após A enviar), os dois lados executam um novo DH com chaves efêmeras frescas. O resultado alimenta o root chain, que gera novas chain keys de envio e recebimento.
DH_novo = DH(efêmera_A_nova, efêmera_B_atual)
(root_novo, CK_envio_novo, CK_recebimento_novo) = KDF(root_atual ∥ DH_novo)Isso é o que dá post-compromise security: se o atacante roubou o estado de A hoje, basta a próxima virada da conversa pra que ele perca o acesso — porque a nova efêmera é desconhecida pra ele.
Regra de ouro: ciphertexts são single-use da perspectiva do receptor — decifrou uma vez, descartou a MK. Recarregar a página e tentar decifrar de novo não funciona. Isso vai te pegar de surpresa; ver o passo 7.4.
Sender Keys (grupos)
Double Ratchet escala mal pra grupos: cada mensagem teria que ser cifrada N-1 vezes (uma sessão por destinatário). Sender Keys resolvem trocando a propriedade de post-compromise por O(1) por mensagem.
Importante: Sender Keys não tem spec oficial publicada no signal.org (a URL
/docs/specifications/sender-keys/retorna 404). É uma extensão usada por WhatsApp e pelo próprio Signal pra grupos grandes. A descrição abaixo é baseada em implementações reais (libsignal) e no whitepaper público do WhatsApp.
Como funciona:
- Cada sender tem uma Sender Key por grupo, composta de uma chain key simétrica + uma signing key (Ed25519) pra autenticar mensagens.
- O sender distribui sua Sender Key pra cada membro via DM Signal (Sender Key Distribution Message — SKDM). A distribuição usa Double Ratchet completo, então é segura.
- Daí em diante, cifra mensagens uma vez com a chain key (avança a cada mensagem) e assina com a signing key. Um único ciphertext pra todos.
A chain key avança em ratchet simétrico (forward secrecy mantida), mas sem DH ratchet — esse é o trade-off explícito. Se a Sender Key vazar, mensagens futuras estão comprometidas até uma redistribuição.
Rotação: quando um membro sai ou é removido, gera-se um novo distributionId (UUID) e os senders distribuem novas Sender Keys. Mensagens futuras ficam fora do alcance do ex-membro.
TOFU (Trust on First Use)
Não tem PKI nem CA. A primeira identidade que A vê de B é "confiada" automaticamente. Mudanças seguintes geram alerta. Pra verificação real, use safety numbers out-of-band — compare a string ou QR code pessoalmente, ou por canal seguro (telefonema, presencial).
1.4 O que o server vê e o que não vê
| Vê | Não vê |
|---|---|
| Ciphertexts (todos) | Plaintexts |
| Identidade pública dos users | Identidade privada |
| SPK / Kyber / OTPs públicas | Chaves privadas correspondentes |
| Quem fala com quem (metadata) | O que falam |
| Timestamps, tamanhos | Conteúdo |
| Backup cifrado da identidade | Senha de derivação do backup |
Metadata fica visível. E2EE não é "anti-vigilância" completa — é "anti-leitura de conteúdo". Se você precisa esconder quem-fala-com-quem, isso é outro problema (mixnet, sealed sender, etc.) e está fora do escopo deste tutorial.
Sealed sender (extensão do Signal) é uma técnica que oculta a identidade do remetente até do servidor: A obtém um "sender certificate" do server, cifra
{cert + ciphertext}com a chave pública de B e envia sem se identificar. O server vê só o destinatário e um blob opaco. Não confundir com E2EE clássico — é uma camada extra de privacidade de metadados, não de sigilo de conteúdo. Geek Social não implementa.
Parte 2 — Decisões antes de codar
Faça essas escolhas explicitamente. Cada uma tem consequências permanentes.
2.1 Single-device vs multi-device
Single-device (escolha do Geek Social hoje):
- ✅ Simples —
deviceId=1hardcoded - ❌ O user não usa em 2 navegadores e não consegue trocar de device sem perder o histórico
Multi-device (Signal/WhatsApp):
- Cada device tem
registrationIdpróprio - Mensagem é cifrada N vezes (uma por device do destinatário)
- Sincronizar sessões entre devices do mesmo user é complexo (protocolo Sesame)
Sesame em uma frase: algoritmo do Signal pra coordenar múltiplos devices por user. Não adiciona criptografia nova — gerencia listas de devices, descoberta, sessões "stale" (obsoletas) e convergência quando dispositivos diferentes recebem mensagens em ordens diferentes. O custo é O(N_devices_destino) por mensagem, mais a complexidade de UX (linkar device novo, gerenciar device perdido).
Comece single-device se o produto permitir. Adicionar multi-device depois é viável; o caminho contrário (removê-lo) é trivial, mas a UX já terá criado expectativa.
2.2 Backup de identidade — sim ou não?
Sem backup → o user que limpa o browser perde a identidade → os peers veem "outra pessoa" e o histórico antigo no DB do server vira lixo indecifrável.
Com backup cifrado por senha de login:
- ✅ Restore transparente em outro device com mesma senha
- ❌ User com senha fraca dá margem pra brute-force offline (mitigado por PBKDF2 ≥ 600k iterações)
- ❌ Users OAuth-only (Google/Steam) não têm senha — sem backup possível
Decisão do Geek Social: backup com senha de login. OAuth-only gera identidade nova em cada device.
2.3 Backup de histórico de mensagens — geralmente, não
Backup de histórico é uma feature à parte. Exige cifrar plaintexts decifrados com uma chave separada (PIN, senha ou drive cifrado) e fazer upload pro server. Custo alto, valor médio — o Signal só lançou isso recentemente, via cloud externa.
Não implemente no MVP. A documentação deixa claro pro user: "trocar device = perder histórico". É a mesma promessa do Signal/WhatsApp.
2.4 Onde fica a libsignal?
Três opções:
| Opção | Pros | Cons |
|---|---|---|
| WASM no browser | E2EE de verdade, server zero-confiança | Bundle pesado (~700KB), API async |
| Native mobile (iOS/Android) | Performance, OS keystore | Não cobre web |
| Server-side | Não é E2EE — é só "TLS reforçado". Não faça. | Anula o propósito todo |
O Geek Social usa @getmaapp/signal-wasm (fork de libsignal compilado pra WASM). Se for native, troque a camada do SignalClient.ts pelo binding nativo — o resto da arquitetura é igual.
Parte 3 — Implementação passo a passo
A partir daqui é prático. Cada passo tem Conceito → O que codar → Onde no Geek Social está.
Passo 1 — Schema de banco (key directory)
Conceito: o server precisa armazenar as chaves públicas dos users e os ciphertexts. Nada privado.
O que codar: uma migration com cinco tabelas.
-- 0045_signal_protocol.sql (resumido)
CREATE TABLE signal_identity_keys (
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
identity_key bytea NOT NULL, -- 32B Curve25519 pública
registration_id integer NOT NULL, -- 14-bit, equiv. device-id
encrypted_backup bytea, -- backup cifrado da identidade privada
backup_salt bytea,
backup_iv bytea,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE signal_signed_prekeys (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
prekey_id integer NOT NULL,
public_key bytea NOT NULL,
signature bytea NOT NULL, -- assinatura Ed25519 sobre public_key
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, prekey_id)
);
CREATE INDEX ON signal_signed_prekeys (user_id, created_at DESC);
CREATE TABLE signal_kyber_prekeys ( /* idêntica ao SPK, com Kyber */ );
CREATE TABLE signal_one_time_prekeys (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
prekey_id integer NOT NULL,
public_key bytea NOT NULL,
PRIMARY KEY (user_id, prekey_id)
);
CREATE TABLE signal_sender_key_distributions (
conversation_id uuid NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
sender_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipient_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ciphertext bytea NOT NULL,
PRIMARY KEY (conversation_id, sender_user_id, recipient_user_id)
);Pontos críticos:
- Index DESC em
signal_signed_prekeys.created_at— você sempre quer a SPK mais recente ON DELETE CASCADEem tudo — quando o user for deletado, leva as chaves junto (LGPD)- Não armazene chaves privadas. Nem brincando. Não.
No Geek Social: api/src/shared/infra/database/migrations/0045_signal_protocol.sql e 0046_conv_sender_key_id.sql (esta última adiciona conversations.sender_key_id pra rotação).
Passo 2 — Endpoints REST do key directory
Conceito: rotas pro cliente publicar e buscar material público de chave. Tudo autenticado, tudo validado.
O que codar:
| Método | Rota | Função |
|---|---|---|
PUT | /crypto/identity | Publica identity key + registrationId |
GET | /crypto/identity/:userId | Busca identity de um user (pra TOFU) |
PUT | /crypto/signed-prekey | Publica SPK assinada |
PUT | /crypto/kyber-prekey | Publica Kyber prekey assinada |
POST | /crypto/one-time-prekeys | Publica lote de OTPs (1–200) |
GET | /crypto/one-time-prekeys/count | Contagem (pra refill) |
GET | /crypto/prekey-bundle/:userId | Pacote pra iniciar handshake |
PUT/GET | /crypto/backup | Backup cifrado da identidade |
PUT/GET | /crypto/sender-key-distribution/... | SKDMs de grupo |
Schema Zod (exemplo):
// api/src/modules/crypto/crypto.schema.ts
const b64 = z.string().regex(/^[A-Za-z0-9+/=]+$/)
const uuid = z.string().uuid()
export const putIdentitySchema = z.object({
identityKey: b64,
registrationId: z.number().int().min(1).max(16383), // 14-bit
})
export const postOtpsSchema = z.object({
prekeys: z.array(z.object({
prekeyId: z.number().int().nonnegative(),
publicKey: b64,
})).min(1).max(200),
})Atomicidade da OTP — importantíssimo:
Duas iniciações concorrentes não podem pegar a mesma OTP; caso contrário, o handshake quebra silenciosamente. popOneTimePrekey() no repository:
// api/src/modules/crypto/crypto.repository.ts
async popOneTimePrekey(userId: string) {
// DELETE...RETURNING dentro de uma CTE com FOR UPDATE SKIP LOCKED
const result = await this.db.execute(sql`
WITH picked AS (
SELECT prekey_id FROM signal_one_time_prekeys
WHERE user_id = ${userId}
ORDER BY prekey_id ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
DELETE FROM signal_one_time_prekeys
WHERE user_id = ${userId} AND prekey_id IN (SELECT prekey_id FROM picked)
RETURNING prekey_id, public_key
`)
return result.rows[0] ?? null
}Validações de negócio:
PUT /crypto/sender-key-distribution/:conversationIddeve verificar se o caller e todos os recipients são membros da conversa. Sem isso, qualquer user pode injetar SKDMs em conversa alheia.- Limite de tamanho do backup (256 KB no Geek Social). Sem limite, um atacante pode encher o DB.
No Geek Social: api/src/modules/crypto/{routes,service,repository,schema}.ts.
Passo 3 — Cliente WASM no frontend
Conceito: uma camada TS sobre a libsignal-wasm que persiste estado em IndexedDB. Esta é a única parte do código que toca chaves diretamente.
O que codar:
// frontend/src/shared/crypto/signal/SignalClient.ts (estrutura)
import init, { WasmSignalClient } from '@getmaapp/signal-wasm'
let wasmReady: Promise<void> | null = null
async function ensureWasm() {
if (!wasmReady) wasmReady = init()
await wasmReady
}
export class SignalSession {
constructor(
private readonly userId: string,
private readonly client: WasmSignalClient,
private readonly idb: IDBDatabase,
) {}
static async create(userId: string): Promise<SignalSession> {
await ensureWasm()
const idb = await openIdb('geek-signal-v1', 3)
const meta = await getMeta(idb, userId)
const client = meta
? WasmSignalClient.from_records(meta.identityPrivateKey, meta.registrationId, ...)
: WasmSignalClient.generate(userId)
return new SignalSession(userId, client, idb)
}
// ...encryptMessage, decryptMessage, processPreKeyBundle, etc.
}Object stores no IndexedDB:
| Store | Chave | Conteúdo |
|---|---|---|
meta | userId | Identity (pub+priv), registrationId, contadores nextPrekeyId/nextSignedPrekeyId/nextKyberPrekeyId |
prekeys | userId:prekeyId | OTPs locais |
signedPrekeys | userId:prekeyId | SPKs (incluindo antigas — pra decifrar mensagens em trânsito) |
kyberPrekeys | userId:prekeyId | Kyber prekeys |
sessions | userId:contactUuid:deviceId | Estado Double Ratchet |
senderKeys | userId:memberUuid:deviceId:distributionId | Sender keys de grupos |
identities | userId:contactUuid | Pub key conhecida + status TOFU |
dmPlaintexts | userId:messageId | Cache de plaintexts (passo 7.4) |
Pontos críticos:
IDBOpenDBRequest.onupgradeneededé o único lugar onde você cria/migra stores. Versione (v1,v2,v3) e tenha migrations pra cada versão.- Não retenha SPKs antigas no server, mas sim no cliente. Mensagem cifrada antes da rotação ainda precisa decifrar.
- Cada operação que escreve no IDB precisa estar dentro de uma transaction
readwrite. WASM é sync; IDB é async. Não tente intercalar. - A identidade privada NUNCA sai do IDB exceto cifrada no backup.
No Geek Social: frontend/src/shared/crypto/signal/SignalClient.ts.
Passo 4 — Bootstrap (login flow)
Conceito: todo login precisa executar um orquestrador que decide entre três caminhos:
O que codar:
// frontend/src/shared/auth/cryptoBootstrap.ts
export async function bootstrapCrypto(opts: { userId: string; loginPassword?: string }) {
const session = await SignalSession.create(opts.userId)
const localIdentity = await session.getLocalIdentity()
const serverIdentity = await api.getIdentity(opts.userId).catch(() => null)
if (localIdentity) {
if (!serverIdentity) await session.publishKeys()
else await session.runKeyMaintenance()
return session
}
const backup = await api.getBackup().catch(() => null)
if (backup && opts.loginPassword) {
try {
await session.loadFromBackup(backup, opts.loginPassword)
await session.publishKeys({ otpsOnly: true })
return session
} catch {
// backup inválido — cai pro fluxo C
}
}
await session.generateIdentity()
await session.publishKeys()
if (opts.loginPassword) await session.saveBackup(opts.loginPassword).catch(() => {})
return session
}Pontos críticos:
await session.publishKeys()é fire-and-forget? Não. Se falhar, os peers não conseguem iniciar sessão. Logue o erro, mas não bloqueie o login (fila de retry).loadFromBackupprecisa restaurar todos os contadores (nextPrekeyId,nextSignedPrekeyId,nextKyberPrekeyId); caso contrário, os IDs colidem com o que o server já tem (bug histórico: commit7b7bb4d).- O fluxo C (identidade nova) é destrutivo para o peer — TOFU vai disparar
IdentityChangedErrorna próxima mensagem que o peer enviar pra esse user. Documente claramente.
No Geek Social: frontend/src/shared/auth/cryptoBootstrap.ts.
Passo 5 — Cifrar e decifrar DMs
Conceito: a primeira mensagem requer handshake (PreKey message, type=3). As subsequentes são SignalMessage (type=1).
Iniciar sessão (sender):
async function startSession(peerUserId: string) {
const bundle = await api.getPrekeyBundle(peerUserId)
// TOFU
const known = await idb.getIdentity(peerUserId)
if (known && !arrayEquals(known.publicKey, bundle.identityKey)) {
await idb.markPendingIdentityChange(peerUserId, bundle.identityKey)
throw new IdentityChangedError(peerUserId)
}
if (!known) await idb.saveIdentity(peerUserId, bundle.identityKey, 'trusted')
await session.processPreKeyBundle(peerUserId, bundle)
}Cifrar:
const { messageType, body } = await session.encryptMessage(peerUserId, plaintextBytes)
const wire = `${messageType}.${base64(body)}` // ex: "3.SGVsbG8="
await api.sendMessage({ conversationId, ciphertext: wire })Decifrar:
const [typeStr, b64body] = wire.split('.', 2)
const messageType = parseInt(typeStr, 10)
const plaintext = await session.decryptMessage(senderId, base64Decode(b64body), messageType)Pontos críticos:
- O wire format
"type.base64"é uma escolha do Geek Social — outras opções: JSON, length-prefixed binary. Escolha uma e seja consistente. messageType=3(PreKey) consome a OTP localmente. O server nunca decide isso.- Se o decrypt falhar com type=3, a OTP foi consumida em vão. Não tente refazer com a mesma — a OTP correspondente já foi deletada do server.
No Geek Social: frontend/src/modules/chat/services/chatCrypto.ts — funções encryptDm e decryptDm.
Passo 6 — Grupos com Sender Keys
Conceito: o distributionId (UUID) identifica a "geração" da sender key. Quando muda, todo mundo regenera.
Cifrar mensagem em grupo:
const ciphertext = await session.encryptGroupMessage(distributionId, plaintextBytes)
const wire = base64(ciphertext) // sem prefixo de typeDecifrar (receptor) com auto-fetch de SKDM se faltando:
async function decryptGroup(senderId: string, distributionId: string, wire: string) {
try {
return await session.decryptGroupMessage(senderId, base64Decode(wire))
} catch (err) {
if (err.code !== 'NO_SENDER_KEY') throw err
// Auto-fetch
const skdm = await api.getSenderKeyDistribution(conversationId, senderId)
const skdmPlain = await decryptDm(senderId, skdm.ciphertext)
await session.processSenderKeyDistribution(senderId, skdmPlain, distributionId)
return await session.decryptGroupMessage(senderId, base64Decode(wire))
}
}Rotação ao remover/sair:
UPDATE conversations SET sender_key_id = gen_random_uuid() WHERE id = $1;
DELETE FROM signal_sender_key_distributions WHERE conversation_id = $1;Os clientes detectam a mudança via socket conversation:updated, regeneram as sender keys e as redistribuem.
No Geek Social: frontend/src/modules/chat/services/chatCrypto.ts:209–214 — função decryptGroup.
Passo 7 — Armadilhas comuns (vão te pegar de surpresa)
7.1 Backup transparente sem PIN extra
UX ruim mata adoção. Pedir um PIN separado pra cada user é fricção desnecessária quando a senha de login já é um segredo derivável.
// Derivar chave do backup da senha de login
const salt = crypto.getRandomValues(new Uint8Array(16))
const iv = crypto.getRandomValues(new Uint8Array(12))
const keyMaterial = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(loginPassword),
'PBKDF2', false, ['deriveKey']
)
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 600_000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']
)
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext)Conteúdo do backup:
{
identityPublicKey, identityPrivateKey, registrationId, deviceId,
nextPrekeyId, nextSignedPrekeyId, nextKyberPrekeyId, // ← essencial
signedPrekeys: [{ id, record(b64) }, ...], // ← essencial
kyberPrekeys: [{ id, record(b64) }, ...]
// OTPs NÃO vão no backup — gere novas no restore
}Por que os contadores são essenciais: sem eles, no restore você gera SPKs com IDs partindo de 0, 1, 2... colidindo com IDs já no server. Mensagens em trânsito cifradas pra prekeyId=5 da geração antiga ficam órfãs. Bug 7b7bb4d no histórico.
Iterações 600k: mínimo recomendado pela OWASP 2023. Argon2id seria melhor, mas exige polyfill no browser. Aceitável.
7.2 Manutenção periódica
Rode no bootstrap, fire-and-forget:
async function runKeyMaintenance() {
const meta = await getMeta()
if (Date.now() - meta.lastSpkRotation > 7 * 24 * 60 * 60 * 1000) {
await rotateSignedPrekey()
}
if (Date.now() - meta.lastKyberRotation > 7 * 24 * 60 * 60 * 1000) {
await rotateKyberPrekey()
}
const { count } = await api.getOtpCount()
if (count < 20) await refillOtps(100)
}Cada passo é independente — a falha de um não bloqueia os outros.
7.3 TOFU + safety numbers
// Comparar
if (known && !arrayEquals(known.publicKey, fetchedIdentityKey)) {
await idb.markPendingIdentityChange(peerUserId, fetchedIdentityKey)
throw new IdentityChangedError(peerUserId)
}
// Gerar safety number pra UI mostrar
const sn = session.generateSafetyNumber(peerUserId, peerIdentityKey)
// sn.display = string formatada (60 dígitos)
// sn.qr = bytes pra QR
// Aceitar a mudança (o user clicou "Confio")
async function acknowledgeIdentityChange(peerUserId: string) {
await idb.promotePendingIdentity(peerUserId)
await idb.clearSession(peerUserId) // próxima msg cria sessão nova
}Não permita que o user pule o TOFU silenciosamente. O propósito do TOFU é dar visibilidade quando algo muda.
7.4 Cache de plaintext (resolver o F5)
Double Ratchet avança a cada decrypt. F5 → server entrega o mesmo ciphertext → decifrar de novo falha → bolha mostra [Mensagem criptografada].
async function renderMessage(msg: { id: string; ciphertext: string }) {
const cached = await idb.getDmPlaintext(msg.id)
if (cached) return cached.plaintext
const plaintext = await decryptDm(msg.senderId, msg.ciphertext)
if (plaintext) await idb.saveDmPlaintext(msg.id, plaintext)
return plaintext
}Faça isso desde o primeiro dia. Foi necessário um commit dedicado (990a91c) pra descobrir que faltava.
Reply previews: mesmo cache. Não tenha codepath separado — o reply preview usa o messageId da msg respondida, então getDmPlaintext(repliedId) resolve igual (commit b3c492e).
7.5 Auto-repair de sessão
Sessões dessincronizam (peer recarregou o app, IDB se corrompeu, etc.). Decrypt falha em loop.
const REPAIR_THROTTLE_MS = 30_000
const lastRepair = new Map<string, number>()
async function decryptDmWithRepair(senderId: string, wire: string) {
try {
return await decryptDm(senderId, wire)
} catch {
const last = lastRepair.get(senderId) ?? 0
if (Date.now() - last < REPAIR_THROTTLE_MS) return null
lastRepair.set(senderId, Date.now())
const bundle = await api.getPrekeyBundle(senderId)
await session.processPreKeyBundle(senderId, bundle)
return await decryptDm(senderId, wire).catch(() => null)
}
}Se ainda falhar, mostre o botão "Tentar novamente" na bolha (commit cdaca62). Não trave a UI.
7.6 Push notifications E2E-safe
O service worker não tem identidade privada (vive em escopo diferente). O push payload deve ser metadata-only:
// api/src/modules/chat/chat.gateway.ts
{ title: 'Nova mensagem', body: '', data: { conversationId, messageId } }O cliente recebe o push → abre o app → busca a msg pelo socket → decifra localmente.
Se o body do push tivesse plaintext, o servidor de push (FCM/APNS) o leria — o que quebraria o E2EE.
Passo 8 — Testes manuais
Automatizar testes de E2EE é difícil (envolve dois clientes em estados diferentes). Use checklists.
Cenários obrigatórios:
- Happy path DM: A envia pra B (sem sessão). B recebe, decifra, responde. A recebe a resposta.
- F5 com histórico: A e B trocaram 10 msgs. B dá F5. Todas as 10 msgs continuam legíveis.
- Identidade nova de um peer: B limpa o IDB e faz login de novo (gera nova identity). A envia uma msg → vê
IdentityChangedError. A aceita → próxima msg funciona. - Backup restore: A limpa IDB. Faz login com mesma senha. Identidade restaurada, manda msg pra B, B decifra OK.
- Backup com senha errada: A limpa IDB. Login com senha nova. Identidade nova gerada — B recebe TOFU alert.
- Grupo de 3: A cria grupo com B, C. Manda msg. B e C decifram. C sai. A manda nova msg. B decifra, C não tem mais sender key (msg invisível pra C).
- OTP exhaustion: force
signal_one_time_prekeysa ficar vazia pro user B. A inicia handshake — deve funcionar (fallback X3DH sem OTP). - Concurrent session init: abrir duas abas do user A simultaneamente, ambas enviando 1ª msg pra B. As duas mensagens devem decifrar (atomicidade da OTP).
No Geek Social: e2e/specs/chat-e2ee.spec.ts (Playwright) cobre 1, 2 e 3. Os outros estão em backlog.
Parte 4 — O que fica de fora deste tutorial
- IDB encryption at-rest — chave derivada da senha + AES-GCM em cada record. Pacote D do roadmap. Sem isso, malware com privilégios do user no device lê tudo (mesma situação do Signal Desktop / WhatsApp Web).
- Sealed sender — esconder de quem uma mensagem é, perante o server. Signal tem; nós não. Não impede correlação por timing/IP.
- Multi-device sync (Sesame) — cada device com
registrationIdpróprio + sincronização cross-device. Exige resolver descoberta de devices, sessões "stale", convergência por recepção. - Backup de histórico de mensagens — vários trade-offs de UX e segurança. Decisão deliberada de não fazer.
- ML-KEM Braid — extensão pós-quântica do Double Ratchet (o PQXDH só protege o handshake inicial). Em desenvolvimento pelo time do Signal, ainda não estabilizada.
Parte 5 — Pra ler depois
Specs oficiais do Signal
- X3DH — Extended Triple Diffie-Hellman — handshake clássico
- PQXDH — Post-Quantum Extended Diffie-Hellman — extensão pós-quântica
- The Double Ratchet Algorithm — gerenciamento de chaves de mensagens
- Sesame — protocolo multi-device
- PQXDH announcement — motivação e contexto histórico
- Sealed Sender — privacidade de metadados
Implementações de referência
- libsignal — Rust, fonte canônica
- @getmaapp/signal-wasm — fork WASM que o Geek Social usa
Outras leituras
- Whitepaper de criptografia do WhatsApp — descreve o uso de Sender Keys em produção
- Signal Private Group System — modelo pairwise pra grupos pequenos
- Criptografia E2EE (referência do Geek Social) — arquitetura final
Código de referência no Geek Social
api/src/modules/crypto/{routes,service,repository,schema}.tsfrontend/src/shared/crypto/signal/SignalClient.tsfrontend/src/shared/auth/cryptoBootstrap.tsfrontend/src/modules/chat/services/chatCrypto.ts