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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'])
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user