Zero-Knowledge + E2EE com Spring Boot e React
Implementando zero knowledge em uma aplicação já existente
31/01/2026

Há um certo tempo desenvolvi um SPA simples para cadastrar o conteúdo das caixas da minha mudança. As informações eram todas guardadas no Local Storage, ou seja... preocupação mínima com privacidade e segurança. Recentemente, dei um passo adiante e implementei uma API para autenticar o usuário e armazenar as informações de maneira segura.
Mas daí surgiu o pensamento de que algumas pessoas poderiam não estar dispostas a compartilhar o conteúdo de suas caixas com um servidor meu, ou de quem quer que seja. Ainda que o provedor do serviço seja bem intencionado e siga à risca seus termos de privacidade, há sempre o risco de dados serem roubados ou vazados. Esse dilema de privacidade de dados sempre aparece quando propomos um software para cuidar de informações pessoais mais delicadas (imagine como exemplo extremo um app para exame de consciência ou anotações de um diário).
Para tentar sanar essa situação, podemos fazer uso de técnicas criptográficas e implementar uma arquitetura zero-knowledge com criptografia ponta-a-ponta, ou E2EE. Numa arquitetura desse tipo, o cliente consegue provar para o servidor que é dono de determinado recurso, sem para isso fornecer a chave que revela o conteúdo do recurso. Hoje, diversas soluções no mercado, como o tresorit e o MEGA alegam ser capazes de armazenar o seus dados sem ter a capacidade de lê-los. Isso é muito legal, mas nunca é simples auditar uma aplicação desse tipo, ainda que tenhamos acesso ao código-fonte. Mas isso é conversa pra outro dia.
Zero-Knowledge: Visão Geral
Embora possa envolver algoritmos criptográficos bastante sofisticados, a ideia por trás de uma arquitetura zero-knowledge é bem simples. O cliente é detentor de uma senha, a qual chamaremos de master key. Dessa master key, derivamos duas chaves: uma auth key e uma data key. A auth key é enviada para o servidor, sendo utilizada para autenticar o usuário e identificá-lo como o dono dos seus dados. A data key é utilizada para encriptar os dados antes de enviá-los para o servidor e para decriptar os dados recebidos do servidor.
O servidor não pode conhecer a data key e nem a master key. Por isso, o algoritmo de derivação das chaves deve ser uma Key Derivation Function (KDF) unidirecional, ou seja, não permite recuperar a chave de entrada a partir das chaves derivadas. Além disso, deve ser capaz de produzir múltiplas chaves a partir da chave de entrada (master key). Um exemplo de algoritmo que atende a esses requisitos é o HKDF.
Boxes: Um "quase zero-knowledge"
No aplicativo boxes, temos usuários, caixas e itens. Um usuário pode ter várias caixas, e cada caixa pode ter vários itens. O frontend é implementado utilizando React, com algumas bibliotecas auxiliares (Tanstack Router, Tanstack Query, axios, etc), e a API backend foi implementada em Java/Spring Boot.
O usuário pode cadastrar, alterar e excluir caixas, além de adicionar ou remover itens das mesmas. Além disso, pode buscar caixas textualmente por rótulo (label) ou descrição.
Um dos problemas de se implementar zero-knowledge para um dado objeto, é que o servidor se torna incapaz de fazer buscas textuais sobre os atributos desse objeto, uma vez que seu conteúdo é cifrado. Além disso, é impedido de fazer ordenação por esses atributos, o que pode quebrar o recurso de paginação. Migrar o Boxes para uma arquitetura 100% zero-knowledge obrigaria que todas as caixas fossem trazidas do servidor de uma só vez, para que o usuário pudesse fazer pesquisas e paginação.
Impacto no desempenho e no fluxo
Fazer um fetch de todas as caixas do usuário de uma vez pode não ser um problema, já que não esperamos um grande volume de dados por usuário em nossa aplicação. No máximo enfrentaríamos uma latência inicial perceptível no caso de um usuário com muitas caixas cadastradas. Em casos de uso com maior volume de dados, precisaríamos de pensar em estratégias para mitigar possíveis problemas de desempenho (como o pré-carregamento de índices ao invés de objetos completos, por exemplo).
No entanto, optei por não trazer grandes impactos ao fluxo da aplicação. Decidi deixar o servidor conhecer as informações básicas das caixas do usuário (nome e descrição), para que busca e paginação continuassem funcionando, limitando o uso de E2EE apenas para os itens de cada caixa. Deste modo, temos privacidade para o conteúdo das caixas sem adicionar muita complexidade à nossa aplicação.
Apenas lembre-se de que esta não é uma aplicação concebida com privacy-by-design, ou seja, estamos apenas adicionando um aspecto de privacidade a uma aplicação já existente. Acho que isso é suficiente para fins de demonstração dos conceitos aqui abordados.
Autenticando o usuário
Convido o leitor a dar uma olhada no código fonte, tanto do frontend, quanto do backend (sugestões e críticas são bem-vindos!). Nesta seção apenas irei destacar algumas partes relevantes para implementação do E2EE.
Iniciamos pela função deriveKeys do arquivo crypto.ts, onde a data key e a auth key são derivados da senha do usuário e de um salt randômico (recebido como parâmetro). Para tanto, utilizamos a biblioteca nativa do navegador Web Crypto API:
export async function deriveKeys(password: string, saltBase64: string) {
const enc = new TextEncoder();
const keyMaterial = await window.crypto.subtle.importKey(
"raw",
enc.encode(password),
{ name: "PBKDF2" },
false,
["deriveKey"]
);
const salt = Uint8Array.from(atob(saltBase64), (c) => c.charCodeAt(0));
// Derivar a master key a partir da password e do salt
const masterKey = await window.crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
keyMaterial,
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
// Deriva a authKey a partir da master key
const rawKey = await window.crypto.subtle.exportKey("raw", masterKey);
const authKeyBuffer = await window.crypto.subtle.digest("SHA-256", rawKey);
// Por motivo de simplicidade, vamos utilizar a própria masterKey como dataKey
const dataKey = masterKey;
const authKey = btoa(String.fromCharCode(...new Uint8Array(authKeyBuffer)));
return { dataKey, authKey };
}Para manter as coisas mais simples, utilizamos a própria master key como data key. Somente a auth key e o salt são enviados para o servidor. O salt é importante para evitar ataques de rainbow table, e o servidor precisa armazená-lo para que possamos obter sempre a mesma master key e auth key a partir da senha do usuário.
O fluxo de novo usuário
No nosso app, o processo de criação de usuário é bem simplificado: um invitation_code é previamente cadastrado no servidor e associado com o email do usuário. O usuário só consegue criar conta se tiver posse desse código de convite (isso não tem muita relação com o assunto do zero-knowledge, mas preferi expor esse fato para que o leitor não estranhe muito esse fluxo "exótico").
O frontend gera um salt aleatório, utiliza a função deriveKeys para gerar a authKey a partir da senha do usuaŕio, e a envia juntamente com os dados do usuário e o salt (que precisa ser guardado no servidor). Como o servidor nunca vê a senha original (somente a auth key), ele não conseguiria utilizar o salt para nada. Eis o service de criação de usuário no backend:
@Service
@RequiredArgsConstructor
class SignupService {
private final UserRepository userRepository;
private final InvitationCodeRepository invitationCodeRepository;
private final PasswordEncoder passwordEncoder;
User execute(String email, String password, String name, String invitationCode, String encryptionSalt) {
checkExisting(email);
checkInvitationCode(email, invitationCode);
User user = new User();
user.setEmail(email);
//O password, na verdade, é o auth key!!!
user.setPassword(passwordEncoder.encode(password));
user.setEncryptionSalt(encryptionSalt);
user.setName(name);
return userRepository.save(user);
}
private void checkExisting(String email) {
if (userRepository.findByEmail(email).isPresent())
throw new BadRequestException("O email já existe.");
}
private void checkInvitationCode(String email, String invitationCode) {
if (invitationCodeRepository.findByEmailAndCode(email, invitationCode).isEmpty())
throw new BadRequestException("O código de convite é inválido.");
}
}O fluxo de login
Para que um usuário previamente cadastrado possa fazer o login, o frontend precisa obter o salt para gerar a auth key a partir da senha. Por isso, precisamos criar um endpoint público em nossa API para retornar o salt a partir do email do usuário. Não se preocupe, pois sem a senha original, o salt é inútil para recuperar nossa master key.
// AuthController.java
...
@GetMapping("/salt")
public String getSalt(@RequestParam String email) {
var user = userService.getByEmail(email);
return user.getEncryptionSalt();
}
...
// SecurityConfiguration.java
...
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
...
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
...
.authorizeHttpRequests(authorize -> authorize
.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
...
.requestMatchers(HttpMethod.POST, "/auth/signin").permitAll()
.requestMatchers(HttpMethod.POST, "/auth/signup").permitAll()
.requestMatchers(HttpMethod.GET, "/auth/salt").permitAll() // Endpoint público para obter o salt
...
.anyRequest().authenticated() // Qualquer outra rota exige autenticação
)
.addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class)
...
.build();
}
...Após obter o salt, o frontend invoca a função deriveKeys para derivar a authKey e poder enviá-la juntamente com o email do usuário para o endpoint de login. Tanto a data key (gerada no frontend) quanto o JWT (retornado do backend) são armazenados em memória, dentro de um React Context com as informações do usuário logado.
Limitações
Como o JWT e a data key são armazenados em memória, a sessão do usuário não persistirá caso ele acesse outra aba ou atualize a página. Além disso, após o JWT expirar, o usuário terá que fazer o login novamente (mas isso pode ser facilmente mitigado com o uso de um endpoint para gerar um novo token antes da expiração).
Isso claramente traz certas inconveniências para a experiência do usuário, mas implementar uma sessão persistente no lado do cliente, mantendo suas chaves seguras contra ataques XSS é um desafio que foge do escopo deste pequeno artigo.
Encriptando e decriptando dados
Como dissemos anteriormente, o servidor é conhecedor de como estruturamos nossos itens (caixas e seus atributos), quantos itens existem em cada caixa, mas não conhece o que são esses itens. Deste modo, sempre que criamos um novo item, devemos encriptá-lo com a data key antes de criar um request POST para o endpoint /boxes/{boxId}/items:
// Trecho do arquivo item-services.ts
export async function addItem(payload: ItemCreateData, dataKey: CryptoKey) {
const { cipherText, iv } = await encryptText(payload.name, dataKey);
payload.name = cipherText;
await api.post(`/boxes/${payload.boxId}/items`, { ...payload, iv });
}No trecho acima do arquivo item-services.ts, utilizamos a função encryptText do arquivo crypto.ts para obter o nome do item encriptado e um vetor de inicialização iv. O iv é um array randômico de 12 bytes que utilizamos, juntamente com a data key, para cifrar o nome do item com o algoritmo AES-GCM. Ele serve como proteção contra a identificação de padrões criptográficos e precisa ser enviado para o servidor para que possamos decriptar o item a posteriori.
E sim, um iv diferente precisa ser gerado para cada item, por motivos matemáticos que, novamente, fogem do escopo de nosso artigo (sugiro a leitura deste post, para quem quiser ter um conhecimento mais profundo sobre o assunto). Deste modo, precisamos adicionar a coluna iv à tabela de itens em nosso banco de dados.
Recuperando itens do servidor
Para retornar as caixas do usuário com os seus itens, de forma paginada, utilizamos o endpoint GET /boxes, obtendo um payload de response parecido com este:
{
"content": [
{
"id": 47,
"label": "JBK-1",
"description": "Caixa com meus livros preferidos",
"color": "RED",
"items": [
{
"id": 59,
"name": "/FbwmLhwAkRz2TEcvCIIawR1SpiX5qvzG2RGK1earvEGMg==",
"iv": "gMb1kcOZmSt8FXQ4"
},
{
"id": 52,
"name": "g4rx+Mp6IjhzMMuNKOAyyV/+qDXb6z/Ec4a6NtBYb7JbKc4=",
"iv": "3SiSDxXirdtVh0bb"
},
{
"id": 42,
"name": "pDvhi6PbyZ+RhF7h5i+Rrb6NOxYSFfr1PLSyOw==",
"iv": "eCBLu2iUbMoqzGj/"
},
]
},
],
"page": {
"size": 20,
"number": 0,
"totalElements": 2,
"totalPages": 1
}
}Para exibir essa caixa com os seus itens no frontend, devemos, para cada item, utilizar a função decryptItem do item-services.ts:
// Trecho do arquivo item-services.ts
export async function decryptItem(item: Item, dataKey: CryptoKey) {
item.name = await decryptText(item.name, item.iv, dataKey);
}
Concluindo
Neste artigo demonstramos como adicionar E2EE e zero-knowledge a uma aplicação já existente, assegurando privacidade para as informações pessoais do usuário. O servidor ainda tem algum conhecimento parcial sobre como os itens são estruturados, para podermos manter features pré-existentes, como busca textual e paginação server-side. Futuramente, posso trazer um caso de estudo onde todas as informações do usuário são criptografadas, demonstrando como podemos lidar com os desafios relacionados a desempenho e tráfego eficiente de dados.
