Como criei um mensageiro E2EE com Spring Boot e WebCrypto — e por que o servidor não vê as mensagens

Como criei um mensageiro E2EE com Spring Boot e WebCrypto — e por que o servidor não vê as mensagens

Um desenvolvedor compartilha sua experiência na criação de um mensageiro com criptografia de ponta a ponta (E2EE), explicando como o servidor não tem acesso às mensagens e detalhando os desafios e soluções envolvidas.

MundiX News·09 de maio de 2026·20 min de leitura·👁 6 views

Como criei um mensageiro E2EE com Spring Boot e WebCrypto — e por que o servidor não vê as mensagens

Olá, pessoal do MundiX.

Sou um desenvolvedor Java e trabalho principalmente com backend: Spring Boot, bancos de dados, integrações, autenticação, WebSocket — tudo o que geralmente fica atrás da interface.

Em algum momento, percebi que uso mensageiros todos os dias, mas não entendo muito bem como eles funcionam por dentro. Ok, JWT, WebSocket, PostgreSQL, Redis — isso é compreensível. Mas o que significa tecnicamente a frase “end-to-end encryption”? Como o servidor entrega as mensagens se não deve lê-las? Onde as chaves residem? O que é armazenado no banco de dados? O que acontece se o usuário tiver dois dispositivos?

Decidi entender na prática. Criei um mensageiro do zero. Chamei-o de Chaos Messenger.

Sinceramente: estudei a parte criptográfica com a ajuda do Claude e do ChatGPT — li as especificações X3DH e Double Ratchet, analisei exemplos, fiz perguntas até que uma imagem completa se formasse. O frontend também foi feito com a ajuda ativa do ChatGPT: sou desenvolvedor backend, React não é meu ambiente principal. Mas a arquitetura, o backend, a integração do WebCrypto, o modelo de envelopes, o armazenamento de mensagens e as decisões fundamentais são minhas.

Aqui, a IA não foi uma substituição da compreensão, mas uma ferramenta — como documentação, Stack Overflow e revisão de colegas. Sem entender o modelo de ameaças e a arquitetura, esse projeto ainda não seria montado.

Neste artigo, contarei como o E2EE funciona por dentro: como uma sessão é estabelecida via X3DH, como cada mensagem recebe uma chave separada via Symmetric Ratchet, por que o servidor armazena apenas envelopes criptografados e quais erros cometi ao longo do caminho.

Stack: Spring Boot 3, React 18, WebCrypto API, PostgreSQL, Redis, WebSocket/STOMP, Prometheus, Grafana.

Aviso importante sobre web-E2EE

Quando digo que o servidor não pode ler as mensagens, quero dizer o backend, o banco de dados, a camada WebSocket e os ciphertext-envelopes já salvos. Eles não têm chaves nem plaintext.

Mas o web-E2EE tem um problema separado: o código frontend também vem do servidor. Teoricamente, um servidor comprometido pode fornecer um JavaScript modificado que roubará as chaves ou o plaintext antes da criptografia. Essa limitação não é específica do meu projeto, mas do modelo de navegador como um todo.

Portanto, a formulação correta é a seguinte: o backend não recebe chaves e não pode descriptografar mensagens já transmitidas ou salvas. A proteção contra a substituição do código do cliente é uma camada de segurança separada: assinatura de builds, verificação independente do cliente, aplicativos desktop/mobile, builds reproduzíveis.

Por que a abordagem usual não funciona

A maioria dos “mensageiros” no GitHub se parece com isso:

java
message.setContent(request.getText());
messageRepository.save(message);

O servidor sabe tudo. Vê cada mensagem. Se o banco de dados vazar, toda a correspondência vaza. Se o servidor for hackeado, leia o que quiser. Se amanhã a empresa decidir vender os dados, tecnicamente nada impede.

O E2EE resolve isso radicalmente: o backend não recebe chaves e não armazena plaintext. A mensagem é criptografada no dispositivo do remetente antes de ser enviada para a rede e descriptografada apenas no dispositivo do destinatário.

Esta não é mais uma questão de política de privacidade no estilo “prometemos não ler”. É uma restrição arquitetural: se o servidor não tiver a chave, ele não poderá transformar o ciphertext de volta em texto.

Isso soa como mágica. Na verdade, são dois protocolos e um pouco de WebCrypto.

A ideia principal: envelopes

Imagine que Alice quer escrever para Bob. Em vez de colocar a carta na mesa e esperar que ninguém a leia, ela a coloca em um envelope lacrado. Apenas Bob pode abrir o envelope com sua chave. O servidor simplesmente transmite o envelope sem olhar para dentro.

É exatamente assim que funciona no código. No meu banco de dados, isso se parece com:

sql
messages.content = '[encrypted]'  -- o servidor não sabe o que está dentro
sql
message_envelopes.ciphertext = 'qzgHSg7zbwU6h8j8...'  -- criptografado com AES-GCM

Quando vi pela primeira vez

[encrypted]

no meu banco de dados em vez de texto, ficou claro que o modelo finalmente estava funcionando corretamente: o servidor criou a mensagem, entregou-a, salvou os metadados, mas nunca descobriu o conteúdo.

E aqui está o que o servidor retorna ao solicitar uma lista de bate-papos via API:

json
{
  "chatId": 32,
  "lastMessage": "[encrypted]",
  "lastMessageAt": "2026-04-28T22:27:35.537016"
}

Não


. Não

[oculto]

. Literalmente

[encrypted]

— porque o servidor não tem outro valor para retornar. Mais tarde, contarei qual bug surgiu disso.

De onde vêm as chaves: X3DH

A principal questão: como Alice e Bob obtêm um segredo compartilhado se nunca conversaram antes? E como fazer isso de forma que o servidor apenas ajude a transferir dados públicos, mas não possa calcular a chave final?

Para isso, o X3DH — Extended Triple Diffie-Hellman, um protocolo do ecossistema Signal — é usado. Sua tarefa é estabelecer um segredo compartilhado entre dois dispositivos usando chaves de longo prazo e temporárias.

O que é armazenado no servidor

Quando um usuário registra um dispositivo, ele carrega um pacote de chaves públicas no servidor:

javascript
// crypto-engine.js — geração de chaves durante o registro do dispositivo
async function buildNewDeviceBundle() {
    const identity     = await generateX25519KeyPair(); // chave de longo prazo do dispositivo
    const signedPreKey = await generateX25519KeyPair(); // deve ser rotacionada periodicamente
    const oneTimePreKeys = [];

    for (let i = 0; i < 50; i++) {
        const kp = await generateX25519KeyPair();
        oneTimePreKeys.push({
            preKeyId: 1000 + i,
            publicKey: await exportRawPublicKey(kp.publicKey),
            privateKeyPkcs8: await exportPkcs8PrivateKey(kp.privateKey)
        });
    }
    // ...
}

Somente as partes públicas são enviadas para o servidor. As chaves privadas são serializadas e armazenadas localmente no navegador — e nunca deixam o dispositivo para a rede.

É importante dizer honestamente aqui: armazenar chaves privadas em localStorage é um compromisso, não um modelo criptográfico ideal.

localStorage está acessível ao código JavaScript da página. Se uma vulnerabilidade XSS aparecer no aplicativo ou se o usuário receber um código frontend substituído, as chaves privadas podem ser roubadas. Isso não quebra o X3DH ou o AES-GCM, mas quebra o ambiente do cliente no qual esses algoritmos são executados.

Uma opção mais rigorosa é usar a Web Crypto API com extractable: false para que a chave privada viva dentro do tempo de execução criptográfico do navegador e não possa ser exportada para bytes. Mas essa abordagem tem uma dificuldade prática: as chaves precisam sobreviver entre as recargas da página, sincronizar com o IndexedDB, restaurar cuidadosamente o estado do dispositivo e não quebrar a UX.

Em aplicativos E2EE baseados em navegador, você geralmente precisa escolher entre várias opções:

  • Chaves serializáveis em localStorage ou IndexedDB — mais fáceis de implementar, mas você precisa ser muito sério sobre XSS e a integridade do código frontend.
  • extractable: false
    • IndexedDB — mais seguro, mas mais difícil de implementar e restaurar o estado.
  • Armazenamento seguro nativo como Android Keystore ou iOS Secure Enclave — a melhor opção para clientes móveis, mas não está disponível para um aplicativo da web comum.

Na versão atual do Chaos Messenger, a primeira opção é usada. Este é um compromisso consciente para um projeto pet/open-source e uma inicialização conveniente no navegador. A transição para chaves não extraíveis e um modelo de armazenamento mais rigoroso está no roteiro.

O ponto-chave: o backend ainda não recebe chaves privadas e não pode descriptografar os ciphertext-envelopes salvos. Mas proteger as chaves no cliente é uma tarefa separada, e não se pode honestamente silenciá-la.

Estabelecimento de sessão

Quando Alice abre a correspondência com Bob pela primeira vez, o seguinte acontece:

javascript
// crypto-engine.js — X3DH do lado do iniciador
async function createInitiatorSessionWrapped(localBundle, targetDevice) {
    const identityPrivate       = await importPkcs8PrivateKey(localBundle.identity.privateKeyPkcs8);
    const ephemeral             = await generateX25519KeyPair(); // chave única apenas para esta sessão
    const remoteIdentityPub     = await importRawPublicKey(targetDevice.identityPublicKey);
    const remoteSignedPreKeyPub = await importRawPublicKey(targetDevice.signedPreKey.publicKey);

    // X3DH usa várias operações DH.
    // DH4 é executado se o destinatário tiver uma one-time prekey.
    const dh1 = await derive32(identityPrivate,      remoteSignedPreKeyPub); // IK_alice · SPK_bob
    const dh2 = await derive32(ephemeral.privateKey, remoteIdentityPub);     // EK_alice · IK_bob
    const dh3 = await derive32(ephemeral.privateKey, remoteSignedPreKeyPub); // EK_alice · SPK_bob

    const parts = [dh1, dh2, dh3];

    if (remoteOneTimePub) {
        const dh4 = await derive32(ephemeral.privateKey, remoteOneTimePub);  // EK_alice · OPK_bob
        parts.push(dh4);
    }

    const combined = concat(...parts);

    // De combined via HKDF, derivamos rootKey e chainKey
    const { rootKey, chainKey } = await deriveRootAndChainKey(combined);
    // ...
}

No X3DH clássico, a quarta operação DH com uma one-time prekey é opcional: ela é executada se o servidor emitiu o OPK disponível do destinatário. Na minha implementação, o dispositivo publica um conjunto de one-time prekeys no registro, então a primeira mensagem geralmente usa DH4. Se os OPKs acabarem, a sessão ainda pode ser estabelecida através dos outros componentes DH, mas esta já é uma opção menos forte.

Bob, recebendo o envelope com a chave pública efêmera de Alice, repete as mesmas operações com suas chaves privadas e recebe o mesmo combined . A matemática é simétrica.

O servidor neste momento vê apenas as chaves públicas e o envelope criptografado. Ele ajuda os dispositivos a se encontrarem, mas não participa do cálculo do segredo.

Obter combined apenas de chaves públicas é praticamente impossível: a segurança aqui se baseia nas propriedades de Diffie-Hellman em Curve25519. Portanto, o servidor pode armazenar e entregar o pacote de prekey, mas não pode derivar o mesmo segredo compartilhado que os dispositivos receberam.

Como cada mensagem é criptografada: Symmetric Ratchet

O X3DH nos dá um chainKey inicial. Mas usar a mesma chave para todas as mensagens é uma má ideia. Se você usar uma chave para toda a correspondência, a comprometimento dessa chave abre imediatamente todo o fluxo de mensagens.

A solução é o ratchet simétrico . Após cada mensagem, a cadeia de chaves avança:

javascript
// crypto-engine.js — um passo do ratchet
async function ratchetStep(chainKeyBytes) {
    const key = await crypto.subtle.importKey(
        'raw', chainKeyBytes,
        { name: 'HMAC', hash: 'SHA-256' },
        false, ['sign']
    );

    // messageKey — chave única para criptografar esta mensagem específica
    const mkBits = await crypto.subtle.sign('HMAC', key, new Uint8Array([0x01]));

    // nextChainKey — chave inicial para a próxima mensagem
    const ckBits = await crypto.subtle.sign('HMAC', key, new Uint8Array([0x02]));

    const messageKey = await crypto.subtle.importKey(
        'raw', new Uint8Array(mkBits),
        { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
    );

    return { messageKey, nextChainKey: new Uint8Array(ckBits) };
}

Visualmente, isso se parece com:

chainKey₀ ──HMAC(·,0x02)──► chainKey₁ ──HMAC(·,0x02)──► chainKey₂ ──►
    │                            │                            │
 HMAC(·,0x01)               HMAC(·,0x01)               HMAC(·,0x01)
    │                            │                            │
    ▼                            ▼                            ▼
messageKey₁                 messageKey₂                 messageKey₃
(AES-GCM msg #1)            (AES-GCM msg #2)            (AES-GCM msg #3)

messageKey é usado para criptografar uma mensagem via AES-GCM, após o qual é destruído . Se um invasor comprometer messageKey₂ — ele só lerá a segunda mensagem.

chainKey₀ não pode ser derivado dele — HMAC-SHA256 é irreversível.

Dentro de tal cadeia simétrica, isso dá sigilo direto para trás na cadeia: conhecendo o atual ou um messageKey separado, você não pode restaurar as chaves antigas. Mas este ainda não é o Double Ratchet completo — mais sobre isso abaixo.

A própria criptografia da mensagem:

javascript
async function encryptWithRatchet(session, plainText) {
    const chainKeyBytes = b64ToBytes(session.sendingChainKey);
    const { messageKey, nextChainKey } = await ratchetStep(chainKeyBytes);

    // Avançamos a cadeia — a antiga chainKey não é mais armazenada
    session.sendingChainKey = bytesToB64(nextChainKey);
    session.sendingIndex++;

    // Criptografamos AES-GCM com um nonce único
    const encrypted = await aesEncryptWithKey(plainText, messageKey);
    return { encrypted, messageIndex: session.sendingIndex - 1 };
}

E aqui está o que vai para o servidor — um exemplo ao vivo do DevTools:

json
{
  "envelope": {
    "ciphertext": "qzgHSg7zbwU6h8j8RqCPUYBWHJLi78eR9C0tj9I=",
    "nonce": "6KPcVjbpM4FUB0Vz",
    "senderIdentityPublicKey": "B4pERe0xKmSdiQPR+kLWWmI0nloC8Za3RBTg+occHF0=",
    "targetDeviceId": "device-2aa3ae0e-ee08-4261-aa09-7d8f800b61e9",
    "messageType": "PREKEY_WHISPER",
    "messageIndex": 0
  }
}

O servidor recebe ciphertext e nonce . Descriptografar sem messageKey é impossível.

Aviso importante: este ainda não é o Double Ratchet completo

Neste projeto, o Symmetric Ratchet é implementado — uma cadeia onde um messageKey separado é derivado de chainKey para cada mensagem, e a própria cadeia avança.

Isso protege mensagens anteriores: se um invasor descobrir a chave atual ou um messageKey separado, ele não poderá reverter o HMAC e obter as chaves antigas.

Mas este não é o Double Ratchet completo do Signal Protocol.

No Double Ratchet completo, há também uma etapa de ratchet DH: as partes executam periodicamente uma nova troca de Diffie-Hellman e atualizam a chave raiz. Isso dá recuperação de invasão — a capacidade de restaurar a segurança de mensagens futuras após a comprometimento de parte do estado.

Na minha implementação, não há etapa de ratchet DH ainda. Se um invasor obtiver o estado atual da sessão no dispositivo e puder continuar lendo-o, ele poderá descriptografar mensagens futuras antes de reinstalar a sessão. Esta é uma limitação honesta da versão atual, e está em primeiro lugar no roteiro.

Multi-dispositivo: um usuário, vários envelopes

O primeiro ponto não óbvio: no E2EE, a mensagem não é endereçada apenas ao usuário, mas a dispositivos específicos do usuário.

Se Bob tiver dois dispositivos — um telefone e um laptop — um encrypted envelope separado é necessário para cada dispositivo. O servidor não pode pegar um envelope, descriptografá-lo e “reembalá-lo” para o segundo dispositivo: ele não tem chaves e não conhece o plaintext.

Isso significa que, ao enviar uma mensagem, você precisa criptografá-la separadamente para cada dispositivo de cada participante do bate-papo.

javascript
// crypto-engine.js — fanout para todos os dispositivos
async function buildFanoutRequest(api, chatId, plainText) {
    const localBundle = await ensureDeviceRegistered(api);

    // Obtemos a lista de todos os dispositivos de todos os participantes do bate-papo
    const resolved = await api('/api/crypto/resolve-chat-devices/' + chatId, { method: 'POST' });

    const envelopes = [];
    for (const targetDevice of resolved.targetDevices) {

        // Para o seu dispositivo — criptografia especial (SELF_WHISPER)
        if (targetDevice.deviceId === localBundle.deviceId) {
            const encrypted = await encryptSelfEnvelope(localBundle, plainText);
            envelopes.push({ ...encrypted, messageType: 'SELF_WHISPER' });
            continue;
        }

        // Para o dispositivo de outra pessoa — X3DH + Ratchet
        let session = getSession(localBundle.deviceId, targetDevice.deviceId);
        let ephemeralPublicKey = null;

        if (!session) {
            // Primeira mensagem — estabelecemos a sessão X3DH
            const created = await createInitiatorSessionWrapped(localBundle, targetDevice);
            session = created.session;
            ephemeralPublicKey = created.ephemeralPublicKey;
        }

        const { encrypted, messageIndex } = await encryptWithRatchet(session, plainText);
        storeSession(localBundle.deviceId, targetDevice.deviceId, session);

        envelopes.push({
            targetDeviceId: targetDevice.deviceId,
            ciphertext: encrypted.ciphertext,
            nonce: encrypted.nonce,
            messageIndex,
            ephemeralPublicKey,  // null se a sessão já existia
            messageType: ephemeralPublicKey ? 'PREKEY_WHISPER' : 'WHISPER'
        });
    }

    return { chatId, envelopes };
}

Para um bate-papo onde cada um tem 2 dispositivos — 4 envelopes por mensagem. Para um grupo de 10 pessoas — potencialmente 20 envelopes. Isso é normal, esse é o preço da segurança.

Servidor: armazenamento e entrega de envelopes

No servidor, a mensagem é criada com o conteúdo [encrypted] e os envelopes são salvos separadamente:

java
// MessageService.java
message.setContent("[encrypted]"); // o servidor não sabe o que está dentro
messageRepository.save(message);
java
// Cada envelope — para um dispositivo específico
Map<String, MessageEnvelope> byDevice = persistEnvelopes(message, sender, request.getEnvelopes());

Após salvar — fanout via WebSocket. Cada dispositivo recebe seu envelope e apenas ele:

java
// MessageService.java — entrega por dispositivo
private void fanoutCreatedEvent(Message message, Map<String, MessageEnvelope> byDevice) {
    byDevice.forEach((deviceId, envelope) ->
        messagingTemplate.convertAndSend(
            "/topic/devices/" + deviceId + "/chats/" + message.getChatId(),
            toDeviceEvent("MESSAGE_CREATED", message, envelope, envelope.getTargetUserId())
        )
    );
}

Esta é uma diferença importante em relação ao bate-papo WebSocket usual. No bate-papo usual, o servidor envia o mesmo evento para todos os participantes. No bate-papo E2EE, o servidor envia eventos diferentes para dispositivos diferentes: a carga útil para cada dispositivo contém seu próprio ciphertext, criptografado sob uma sessão separada.

O tópico /topic/devices/{deviceId}/chats/{chatId} é estritamente pessoal. O dispositivo A não recebe o envelope do dispositivo B. Sem transmissão — apenas entrega direcionada.

Arquitetura completa

Browser
├── React 18 + Vite
├── crypto-engine.js        ← X3DH · Symmetric Ratchet · AES-GCM · WebCrypto
├── local device bundle     ← identity key · signed prekey · one-time prekeys
├── REST /api/*             ← auth · profile · chats · devices · prekeys
└── WebSocket /ws           ← per-device STOMP topics

Spring Boot Backend
├── auth/                   ← phone OTP · email · JWT · refresh tokens
├── crypto/                 ← device registry · prekey bundles · envelope fanout
├── chat/                   ← chats · participants · message metadata
├── message/                ← encrypted envelopes · receipts · events
├── infra/ws/               ← WebSocket · JWT auth · device routing
└── infra/presence/         ← online status · typing

PostgreSQL
└── users · devices · chats · messages([encrypted]) · envelopes(ciphertext, nonce)

Redis
└── refresh tokens · online presence · SMS rate limits

Observability
└── Actuator · Prometheus · Grafana

Bug que não percebi por muito tempo

O painel de bate-papos mostra a visualização da última mensagem. Eu implementei isso via ChatService.getMyChats() — carrego a última mensagem do banco de dados e a entrego ao cliente.

Eu executo — na lista de bate-papos, todos dizem [encrypted] .

Claro. O servidor não sabe o que está escrito lá.

Pensei por meia hora em como resolver isso no servidor. Então percebi: não é possível resolver isso no servidor — ele não tem chaves . A solução é apenas no cliente.

Depois que o usuário abre o bate-papo e as mensagens são descriptografadas — armazenamos a última na memória:

javascript
// Após descriptografar mensagens em useMessages.js
previewCache.set(chatId, decryptedText.slice(0, 60));

// No componente ChatList — usamos o cache
const preview = previewCache.get(chatId) ?? '🔒 Criptografado';

Este é um bom exemplo de como o E2EE muda o pensamento usual do desenvolvedor backend. Em um aplicativo normal, a visualização é um campo em uma consulta SQL. Em um aplicativo E2EE, a visualização é o estado local do cliente, porque apenas o cliente viu o plaintext.

Solução simples. Mas para chegar a ela, foi preciso aceitar totalmente a ideia de que o servidor não está envolvido aqui — e parar de tentar resolver o problema do lado dele.

Limitação de taxa: um buraco que é fácil de perder

O endpoint /api/auth/send-code envia um SMS com um código. Sem proteção, qualquer script pode acioná-lo milhares de vezes — isso é chamado de SMS pumping fraud, os SMS custam dinheiro real.

O Redis já estava conosco para armazenar status online. Adicionei limitação de taxa sobre ele:

java
// SmsRateLimiter.java
public void checkAndIncrement(String phone) {
    // Não mais que 3 SMS em 10 minutos
    checkLimit("sms:rate:short:" + phone, 3, Duration.ofMinutes(10));
    // Não mais que 10 SMS em 24 horas
    checkLimit("sms:rate:day:"   + phone, 10, Duration.ofHours(24));
}

private void checkLimit(String key, int maxAttempts, Duration window) {
    Long count = redisTemplate.opsForValue().increment(key);
    if (count == 1) {
        redisTemplate.expire(key, window);
    }
    if (count > maxAttempts) {
        long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
        throw new RateLimitException("Too many requests", ttl);
    }
}

Se excedido — HTTP 429 com o cabeçalho Retry-After . O cliente sabe em quantos segundos pode tentar novamente.

Uma nuance importante: na implementação atual, se o Redis estiver indisponível, o serviço não bloqueará a autorização completamente. Para um projeto pet, este é um compromisso aceitável: é melhor arriscar um SMS extra do que derrubar a entrada no aplicativo.

Em produção, eu faria mais rigoroso: fallback in-memory limite por instância, limites separados por IP e telefone, lógica anti-fraude e alertas sobre picos de envio de códigos.

Autorização WebSocket

Uma história separada é a autorização de conexões WebSocket. Os endpoints HTTP são protegidos pelo Spring Security automaticamente, mas WebSocket é outra questão. A conexão STOMP é estabelecida uma vez, e você precisa verificar o JWT em cada conexão.

java
// WebSocketAuthChannelInterceptor.java
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
    StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);

    if (StompCommand.CONNECT.equals(accessor.getCommand())) {
        String token = accessor.getFirstNativeHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            throw new AuthException("Missing WebSocket auth token");
        }
        // Validamos o JWT e estabelecemos o principal
        Authentication auth = jwtAuthProvider.authenticate(token.substring(7));
        accessor.setUser(auth);
    }
    return message;
}

É importante não apenas verificar o JWT, mas também vincular a conexão WebSocket a um dispositivo específico. Pode haver um usuário, mas ele tem vários dispositivos, e o encrypted envelope é direcionado para deviceId.

Portanto, ao conectar, verifico não apenas o token, mas também o X-Device-Id: o dispositivo deve ser registrado e pertencer ao usuário atual. Caso contrário, é fácil transformar acidentalmente a entrega E2EE por dispositivo de volta em uma transmissão normal por usuário.

O que aconteceu — capturas de tela ao vivo

O que foi implementado:

  • Modelo E2EE com encrypted envelopes por dispositivo
  • Configuração de sessão X3DH + Symmetric Ratchet + AES-GCM
  • Multi-dispositivo
  • Bate-papos pessoais e em grupo
  • Entrega em tempo real via WebSocket/STOMP
  • Status ENVIADO → ENTREGUE → LIDO
  • Edição e exclusão suave de mensagens
  • Presença online, indicador de digitação
  • Anexos de fotos
  • Pesquisa de usuários
  • Limitação de taxa em SMS via Redis
  • Métricas Prometheus + painel Grafana
  • Swagger UI com autorização JWT
  • 24 testes de backend no Testcontainers, 12 frontend no Vitest, E2E no Playwright
  • GitHub Actions CI

O que ainda não foi feito:

  • Double Ratchet completo com etapa de ratchet DH e recuperação de invasão
  • Rotação de prekey assinada e reabastecimento cuidadoso de one-time prekeys
  • Um modelo de armazenamento de chaves privadas mais rigoroso no cliente: CryptoKey não extraível + IndexedDB
  • Proteção contra a substituição do código frontend: assinatura de builds, verificação independente do cliente, builds reproduzíveis
  • Cliente Android com Android Keystore
  • Um provedor de SMS real em vez de código nos logs de backend
  • Notificações push sem vazamento do conteúdo das mensagens
  • Um modelo de metadados mais rigoroso para bate-papos em grupo

O principal insight

E2EE é uma solução arquitetural, não uma biblioteca.

Você não pode pegar um bate-papo Spring Boot normal e simplesmente “ativar a criptografia”. Você precisa projetar o sistema desde o início para que o backend não seja um participante na zona de confiança: ele não deve receber plaintext, não deve ter chaves e não deve ser capaz de remontar a mensagem a partir dos dados no banco de dados.

Isso muda quase tudo:

  • a estrutura do banco de dados — em vez de texto, aparecem encrypted envelopes;
  • a API — o servidor retorna [encrypted] , não a visualização da mensagem;
  • WebSocket — a entrega não é por usuário, mas por um dispositivo específico;
  • multi-dispositivo — uma mensagem se transforma em vários ciphertext-envelopes;
  • frontend — torna-se uma parte criptográfica completa do sistema, e não apenas a UI.

O segundo insight: um mensageiro não é um “bate-papo com WebSocket”. No modelo E2EE, é um sistema de entrega de envelopes criptografados com endereçamento por dispositivo. Assim que você aceita isso, muitas soluções estranhas à primeira vista se tornam lógicas.

Repositório

O código está aberto:

github.com/vaazhen/chaos-messenger

O repositório tem um README em russo e inglês, diagramas, capturas de tela, auditoria de segurança, Docker Compose e inicialização com um comando.

O projeto não pretende ser um cripto-mensageiro de nível de produção como o Signal. Este é um protótipo de código aberto de aprendizado e engenharia, cujo objetivo é mostrar como o E2EE muda a arquitetura do backend, frontend e entrega em tempo real.

Se você fez algo semelhante — especialmente interessante

🛡️⚡

Pare de pesquisar. Comece a hackear.

O MundiX é seu copiloto de pentest com IA: comandos exatos, análise de outputs e próximo passo na kill chain — em segundos.

Testar grátis por 7 dias →

Sem cartão para começar · Planos a partir de R$49/mês

📤 Compartilhar & Baixar

📩 Newsletter MundiX

Receba novidades de cibersegurança + um checklist de pentest grátis. Sem spam.

Ao assinar você concorda em receber e-mails. Cancele quando quiser.