Geek Social — Documentação
Tutoriais

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?

ModeloQuem lê o plaintext
Sem criptoServer, operador, atacante MITM, atacante no DB
TLS onlyServer, operador, atacante no DB
E2EEApenas 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):

DHOperaçãoGarante
DH1DH(IK_A, SPK_B)Autenticação de A perante B (prova posse de IK_A)
DH2DH(EK_A, IK_B)Autenticação de B perante A (depende de IK_B)
DH3DH(EK_A, SPK_B)Sigilo via efêmera + chave de médio prazo
DH4DH(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 chamadaprocess_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.

TermoPapelVida útil
Chain Key (CK)"Adubo" — nunca cifra direto, só gera próxima CK + MKEnquanto a cadeia estiver ativa
Message Key (MK)Cifra/decifra exatamente uma mensagemDeletada 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:

  1. Cada sender tem uma Sender Key por grupo, composta de uma chain key simétrica + uma signing key (Ed25519) pra autenticar mensagens.
  2. 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.
  3. 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ê

Não vê
Ciphertexts (todos)Plaintexts
Identidade pública dos usersIdentidade privada
SPK / Kyber / OTPs públicasChaves privadas correspondentes
Quem fala com quem (metadata)O que falam
Timestamps, tamanhosConteúdo
Backup cifrado da identidadeSenha 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=1 hardcoded
  • ❌ 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 registrationId pró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çãoProsCons
WASM no browserE2EE de verdade, server zero-confiançaBundle pesado (~700KB), API async
Native mobile (iOS/Android)Performance, OS keystoreNão cobre web
Server-sideNã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 CASCADE em 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étodoRotaFunção
PUT/crypto/identityPublica identity key + registrationId
GET/crypto/identity/:userIdBusca identity de um user (pra TOFU)
PUT/crypto/signed-prekeyPublica SPK assinada
PUT/crypto/kyber-prekeyPublica Kyber prekey assinada
POST/crypto/one-time-prekeysPublica lote de OTPs (1–200)
GET/crypto/one-time-prekeys/countContagem (pra refill)
GET/crypto/prekey-bundle/:userIdPacote pra iniciar handshake
PUT/GET/crypto/backupBackup 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/:conversationId deve 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:

StoreChaveConteúdo
metauserIdIdentity (pub+priv), registrationId, contadores nextPrekeyId/nextSignedPrekeyId/nextKyberPrekeyId
prekeysuserId:prekeyIdOTPs locais
signedPrekeysuserId:prekeyIdSPKs (incluindo antigas — pra decifrar mensagens em trânsito)
kyberPrekeysuserId:prekeyIdKyber prekeys
sessionsuserId:contactUuid:deviceIdEstado Double Ratchet
senderKeysuserId:memberUuid:deviceId:distributionIdSender keys de grupos
identitiesuserId:contactUuidPub key conhecida + status TOFU
dmPlaintextsuserId:messageIdCache 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).
  • loadFromBackup precisa restaurar todos os contadores (nextPrekeyId, nextSignedPrekeyId, nextKyberPrekeyId); caso contrário, os IDs colidem com o que o server já tem (bug histórico: commit 7b7bb4d).
  • O fluxo C (identidade nova) é destrutivo para o peer — TOFU vai disparar IdentityChangedError na 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 type

Decifrar (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:

  1. Happy path DM: A envia pra B (sem sessão). B recebe, decifra, responde. A recebe a resposta.
  2. F5 com histórico: A e B trocaram 10 msgs. B dá F5. Todas as 10 msgs continuam legíveis.
  3. 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.
  4. Backup restore: A limpa IDB. Faz login com mesma senha. Identidade restaurada, manda msg pra B, B decifra OK.
  5. Backup com senha errada: A limpa IDB. Login com senha nova. Identidade nova gerada — B recebe TOFU alert.
  6. 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).
  7. OTP exhaustion: force signal_one_time_prekeys a ficar vazia pro user B. A inicia handshake — deve funcionar (fallback X3DH sem OTP).
  8. 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 registrationId pró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

Implementações de referência

Outras leituras

Código de referência no Geek Social

  • api/src/modules/crypto/{routes,service,repository,schema}.ts
  • frontend/src/shared/crypto/signal/SignalClient.ts
  • frontend/src/shared/auth/cryptoBootstrap.ts
  • frontend/src/modules/chat/services/chatCrypto.ts

On this page