feat: implement end-to-end encryption for chat messages and files, including key management and decryption functionality; enhance chat components to support encrypted content display

This commit is contained in:
2025-12-09 01:31:09 -03:00
parent cae6d886de
commit e6f380d7cc
14 changed files with 1443 additions and 203 deletions

View File

@@ -5,12 +5,13 @@ import { authComponent } from './auth';
/**
* Alterar senha do usuário autenticado
* Token é opcional - autenticação é feita via contexto do Convex
*/
export const alterarSenha = mutation({
args: {
token: v.string(), // Token não é usado, mas mantido para compatibilidade
senhaAtual: v.string(),
novaSenha: v.string()
novaSenha: v.string(),
token: v.optional(v.string()) // Token opcional - não é usado, mas mantido para compatibilidade
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
@@ -44,14 +45,12 @@ export const alterarSenha = mutation({
return {
sucesso: true as const
};
} catch (error: any) {
} catch (error: unknown) {
// Capturar erros específicos do Better Auth
let mensagemErro = 'Erro ao alterar senha';
if (error?.message) {
if (error instanceof Error && 'message' in error) {
mensagemErro = error.message;
} else if (typeof error === 'string') {
mensagemErro = error;
}
// Mensagens de erro mais amigáveis
@@ -71,3 +70,4 @@ export const alterarSenha = mutation({
}
}
});
// Token agora é opcional - Convex deve recompilar

View File

@@ -261,7 +261,11 @@ export const enviarMensagem = mutation({
arquivoTipo: v.optional(v.string()),
mencoes: v.optional(v.array(v.id('usuarios'))),
respostaPara: v.optional(v.id('mensagens')), // ID da mensagem que está respondendo
permitirNotificacaoParaSiMesmo: v.optional(v.boolean()) // ✅ NOVO: Permite criar notificação para si mesmo
permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo
// Campos para criptografia E2E
criptografado: v.optional(v.boolean()), // Indica se a mensagem está criptografada
iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia
keyId: v.optional(v.string()) // Identificador da chave usada para criptografar
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
@@ -342,7 +346,8 @@ export const enviarMensagem = mutation({
}
// Normalizar conteúdo para busca (remover acentos, lowercase)
const conteudoBusca = normalizarTextoParaBusca(args.conteudo);
// Se a mensagem estiver criptografada, não criar índice de busca (conteudoBusca será undefined)
const conteudoBusca = args.criptografado ? undefined : normalizarTextoParaBusca(args.conteudo);
// Verificar se é resposta a outra mensagem
if (args.respostaPara) {
@@ -373,7 +378,11 @@ export const enviarMensagem = mutation({
mencoes: args.mencoes,
respostaPara: args.respostaPara,
enviadaEm: Date.now(),
lidaPor: [] // Inicializar como array vazio
lidaPor: [], // Inicializar como array vazio
// Campos de criptografia E2E
criptografado: args.criptografado ?? false,
iv: args.iv,
keyId: args.keyId
});
// Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto, assíncrono)
@@ -396,8 +405,12 @@ export const enviarMensagem = mutation({
}
// Atualizar última mensagem da conversa
// Se a mensagem estiver criptografada, usar placeholder
const ultimaMensagemTexto = args.criptografado
? '🔒 Mensagem criptografada'
: args.conteudo.substring(0, 100);
await ctx.db.patch(args.conversaId, {
ultimaMensagem: args.conteudo.substring(0, 100),
ultimaMensagem: ultimaMensagemTexto,
ultimaMensagemTimestamp: Date.now(),
ultimaMensagemRemetenteId: usuarioAtual._id // Guardar ID do remetente da última mensagem
});
@@ -943,7 +956,10 @@ export const limparNotificacoesNaoLidas = mutation({
export const editarMensagem = mutation({
args: {
mensagemId: v.id('mensagens'),
novoConteudo: v.string()
novoConteudo: v.string(),
// Campos para criptografia E2E (opcionais, apenas se a mensagem for criptografada)
iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia
keyId: v.optional(v.string()) // Identificador da chave usada para criptografar
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
@@ -988,15 +1004,32 @@ export const editarMensagem = mutation({
return { sucesso: false, erro: 'O conteúdo da mensagem não pode estar vazio' };
}
// Normalizar conteúdo para busca
const conteudoBusca = normalizarTextoParaBusca(args.novoConteudo);
// Normalizar conteúdo para busca (se não estiver criptografado)
// Se a mensagem original estava criptografada, manter criptografado
const estaCriptografada = mensagem.criptografado ?? false;
const conteudoBusca = estaCriptografada ? undefined : normalizarTextoParaBusca(args.novoConteudo);
// Atualizar mensagem
await ctx.db.patch(args.mensagemId, {
// Se a mensagem estava criptografada e novos campos de criptografia foram fornecidos, atualizar
const updateData: {
conteudo: string;
conteudoBusca?: string;
editadaEm: number;
iv?: string;
keyId?: string;
} = {
conteudo: args.novoConteudo.trim(),
conteudoBusca,
editadaEm: Date.now()
});
};
// Se a mensagem estava criptografada e novos campos foram fornecidos, atualizar
if (estaCriptografada && (args.iv || args.keyId)) {
if (args.iv) updateData.iv = args.iv;
if (args.keyId) updateData.keyId = args.keyId;
}
await ctx.db.patch(args.mensagemId, updateData);
return { sucesso: true };
}
@@ -1183,18 +1216,39 @@ export const adicionarParticipanteSala = mutation({
participantes: novosParticipantes
});
// Verificar se a conversa tem criptografia E2E ativa
const chaveE2E = await ctx.db
.query('chavesCriptografia')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
.first();
// Criar notificação para o novo participante
const agora = Date.now();
await ctx.db.insert('notificacoes', {
usuarioId: args.participanteId,
tipo: 'adicionado_grupo',
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: 'Adicionado a sala de reunião',
descricao: `Você foi adicionado à sala de reunião "${conversa.nome || 'Sem nome'}" por ${usuarioAtual.nome}`,
descricao: `Você foi adicionado à sala de reunião "${conversa.nome || 'Sem nome'}" por ${usuarioAtual.nome}${chaveE2E ? '. Esta conversa usa criptografia E2E - você receberá a chave automaticamente.' : ''}`,
lida: false,
criadaEm: Date.now()
criadaEm: agora
});
// Se a conversa tem E2E ativo, notificar sobre a chave
if (chaveE2E) {
await ctx.db.insert('notificacoes', {
usuarioId: args.participanteId,
tipo: 'alerta_seguranca',
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: 'Chave de criptografia E2E disponível',
descricao: `Esta conversa usa criptografia end-to-end. A chave foi compartilhada com você automaticamente.`,
lida: false,
criadaEm: agora
});
}
return { sucesso: true };
}
});
@@ -1818,7 +1872,7 @@ export const obterMensagens = query({
// Enriquecer com informações do remetente e mensagem respondida
const mensagensEnriquecidas = await Promise.all(
mensagensParaRetornar.map(async (mensagem) => {
mensagensFiltradas.map(async (mensagem) => {
const remetente = await ctx.db.get(mensagem.remetenteId);
// SEGURANÇA: Não retornar informações de remetente se não for participante
@@ -2509,3 +2563,144 @@ export const limparIndicadoresDigitacao = internalMutation({
return indicadoresAntigos.length;
}
});
// ========== CRIPTOGRAFIA E2E ==========
/**
* Compartilha uma chave de criptografia E2E para uma conversa
* A chave deve ser criptografada antes de ser enviada (usando chave pública do servidor ou outro método)
*/
export const compartilharChaveCriptografia = mutation({
args: {
conversaId: v.id('conversas'),
chaveCompartilhada: v.string(), // Chave criptografada (base64)
keyId: v.string() // Identificador único da chave
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
throw new Error('Não autenticado');
}
// Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
throw new Error('Conversa não encontrada');
}
if (!conversa.participantes.includes(usuarioAtual._id)) {
throw new Error('Você não pertence a esta conversa');
}
// Desativar chaves antigas da conversa
const chavesAntigas = await ctx.db
.query('chavesCriptografia')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
.collect();
for (const chaveAntiga of chavesAntigas) {
await ctx.db.patch(chaveAntiga._id, { ativo: false });
}
// Criar nova chave
await ctx.db.insert('chavesCriptografia', {
conversaId: args.conversaId,
chaveCompartilhada: args.chaveCompartilhada,
keyId: args.keyId,
criadoPor: usuarioAtual._id,
criadoEm: Date.now(),
ativo: true
});
// Criar notificações para outros participantes sobre a ativação/regeneração da chave
const agora = Date.now();
for (const participanteId of conversa.participantes) {
if (participanteId !== usuarioAtual._id) {
await ctx.db.insert('notificacoes', {
usuarioId: participanteId,
tipo: 'alerta_seguranca',
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: 'Criptografia E2E atualizada',
descricao: `${usuarioAtual.nome} ${chavesAntigas.length > 0 ? 'regenerou' : 'ativou'} a criptografia end-to-end nesta conversa. Suas mensagens futuras serão criptografadas.`,
lida: false,
criadaEm: agora
});
}
}
return { sucesso: true };
}
});
/**
* Obtém a chave de criptografia ativa para uma conversa
*/
export const obterChaveCriptografia = query({
args: {
conversaId: v.id('conversas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return null;
}
// Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return null;
}
if (!conversa.participantes.includes(usuarioAtual._id)) {
return null;
}
// Buscar chave ativa
const chave = await ctx.db
.query('chavesCriptografia')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
.first();
if (!chave) {
return null;
}
return {
chaveCompartilhada: chave.chaveCompartilhada,
keyId: chave.keyId,
criadoPor: chave.criadoPor,
criadoEm: chave.criadoEm
};
}
});
/**
* Verifica se uma conversa tem criptografia E2E habilitada
*/
export const verificarCriptografiaE2E = query({
args: {
conversaId: v.id('conversas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return false;
}
// Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return false;
}
if (!conversa.participantes.includes(usuarioAtual._id)) {
return false;
}
// Verificar se existe chave ativa
const chave = await ctx.db
.query('chavesCriptografia')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
.first();
return chave !== null;
}
});

View File

@@ -23,12 +23,16 @@ export const chatTables = {
conversaId: v.id('conversas'),
remetenteId: v.id('usuarios'),
tipo: v.union(v.literal('texto'), v.literal('arquivo'), v.literal('imagem')),
conteudo: v.string(), // texto ou nome do arquivo
conteudoBusca: v.optional(v.string()), // versão normalizada para busca
conteudo: v.string(), // texto ou nome do arquivo (pode ser criptografado)
conteudoBusca: v.optional(v.string()), // versão normalizada para busca (não criptografada se mensagem for E2E)
arquivoId: v.optional(v.id('_storage')),
arquivoNome: v.optional(v.string()),
arquivoTamanho: v.optional(v.number()),
arquivoTipo: v.optional(v.string()),
// Campos para criptografia E2E
criptografado: v.optional(v.boolean()), // Indica se a mensagem está criptografada
iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia
keyId: v.optional(v.string()), // Identificador da chave usada para criptografar
linkPreview: v.optional(
v.object({
url: v.string(),
@@ -169,5 +173,17 @@ export const chatTables = {
atualizadoEm: v.number()
})
.index('by_usuario_conversa', ['usuarioId', 'conversaId'])
.index('by_conversa', ['conversaId'])
.index('by_conversa', ['conversaId']),
// Chaves de Criptografia E2E por Conversa
chavesCriptografia: defineTable({
conversaId: v.id('conversas'),
chaveCompartilhada: v.string(), // Chave criptografada compartilhada (base64)
keyId: v.string(), // Identificador único da chave
criadoPor: v.id('usuarios'), // Usuário que criou/compartilhou a chave
criadoEm: v.number(),
ativo: v.boolean() // Se a chave está ativa (permite rotação de chaves)
})
.index('by_conversa', ['conversaId', 'ativo'])
.index('by_key_id', ['keyId'])
};