diff --git a/apps/web/src/lib/components/chat/ChatWindow.svelte b/apps/web/src/lib/components/chat/ChatWindow.svelte index 1fad7cf..0d7dea1 100644 --- a/apps/web/src/lib/components/chat/ChatWindow.svelte +++ b/apps/web/src/lib/components/chat/ChatWindow.svelte @@ -11,6 +11,7 @@ import SalaReuniaoManager from './SalaReuniaoManager.svelte'; import CallWindow from '../call/CallWindow.svelte'; import ErrorModal from '../ErrorModal.svelte'; + import E2EManagementModal from './E2EManagementModal.svelte'; //import { getAvatarUrl } from '$lib/utils/avatarGenerator'; import { browser } from '$app/environment'; import { traduzirErro } from '$lib/utils/erroHelpers'; @@ -19,14 +20,14 @@ X, ArrowLeft, LogOut, - MoreVertical, Users, Clock, - XCircle, Phone, Video, - ChevronDown, - Search + Search, + Lock, + MoreVertical, + XCircle } from 'lucide-svelte'; //import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte'; @@ -45,21 +46,18 @@ let showSalaManager = $state(false); let showAdminMenu = $state(false); let showNotificacaoModal = $state(false); + let showE2EModal = $state(false); let iniciandoChamada = $state(false); let chamadaAtiva = $state | null>(null); let showSearch = $state(false); let searchQuery = $state(''); - let searchResults = $state>([]); + let searchResults = $state>([]); let searching = $state(false); let selectedSearchResult = $state(-1); - - // Estados para modal de erro let showErrorModal = $state(false); let errorTitle = $state('Erro'); let errorMessage = $state(''); let errorInstructions = $state(undefined); - let errorDetails = $state(undefined); - const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, { conversaId: conversaId as Id<'conversas'> }); @@ -70,6 +68,11 @@ conversaId: conversaId as Id<'conversas'> }); + // Verificar se a conversa tem criptografia E2E habilitada + const temCriptografiaE2E = useQuery(api.chat.verificarCriptografiaE2E, { + conversaId: conversaId as Id<'conversas'> + }); + const conversa = $derived(() => { console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId); console.log('📋 [ChatWindow] Conversas disponíveis:', conversas?.data); @@ -297,9 +300,29 @@
-

- {getNomeConversa()} -

+ +
+

+ {getNomeConversa()} +

+ {#if temCriptografiaE2E?.data} + + {/if} +
{#if getStatusMensagem()}

{getStatusMensagem()} @@ -322,7 +345,7 @@ {conversa()?.participantesInfo?.length || 0} {conversa()?.participantesInfo?.length === 1 ? 'participante' : 'participantes'}

- {#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0} + {#if conversa()?.participantesInfo && conversa()?.participantesInfo?.length > 0}
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)} @@ -609,6 +632,27 @@
{/if} + + + +
+ + +
+ +
+
+
+ {#if temCriptografiaE2E?.data} + +
+

Criptografia E2E Ativa

+

+ Suas mensagens estão protegidas com criptografia end-to-end +

+
+ {:else} + +
+

Criptografia E2E Desativada

+

+ Suas mensagens não estão criptografadas +

+
+ {/if} +
+
+
+ + + {#if temCriptografiaE2E?.data && chaveAtual?.data} +
+
+

Informações da Chave

+
+
+ ID da Chave: + {chaveAtual.data.keyId.substring(0, 16)}... +
+
+ Criada em: + {formatarData(chaveAtual.data.criadoEm)} +
+
+ Chave local: + + {hasEncryptionKey(conversaId) ? '✓ Armazenada' : '✗ Não encontrada'} + +
+
+
+
+ {/if} + + +
+ +
+

Como funciona a criptografia E2E?

+
    +
  • Suas mensagens são criptografadas no seu dispositivo antes de serem enviadas
  • +
  • Apenas você e os participantes da conversa podem descriptografar as mensagens
  • +
  • O servidor não consegue ler o conteúdo das mensagens criptografadas
  • +
  • Mensagens antigas continuam legíveis mesmo após regenerar a chave
  • +
+
+
+ + +
+ {#if temCriptografiaE2E?.data} + + + {:else} + + {/if} +
+
+
+ + diff --git a/apps/web/src/lib/components/chat/MessageInput.svelte b/apps/web/src/lib/components/chat/MessageInput.svelte index b6562f3..21c258b 100644 --- a/apps/web/src/lib/components/chat/MessageInput.svelte +++ b/apps/web/src/lib/components/chat/MessageInput.svelte @@ -4,6 +4,15 @@ import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { onMount } from 'svelte'; import { Paperclip, Smile, Send } from 'lucide-svelte'; + import { + encryptMessage, + encryptFile, + loadEncryptionKey, + storeEncryptionKey, + exportKey, + type EncryptedMessage + } from '$lib/utils/e2eEncryption'; + import { obterChaveCriptografia, armazenarChaveCriptografia } from '$lib/stores/chatStore'; interface Props { conversaId: Id<'conversas'>; @@ -28,6 +37,9 @@ const client = useConvexClient(); const conversas = useQuery(api.chat.listarConversas, {}); + // Verificar se a conversa tem criptografia E2E habilitada + const temCriptografiaE2E = useQuery(api.chat.verificarCriptografiaE2E, { conversaId }); + // Constantes de validação const MAX_MENSAGEM_LENGTH = 5000; // Limite de caracteres por mensagem const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -168,7 +180,7 @@ // Auto-resize do textarea e detectar menções function handleInput(e: Event) { const target = e.target as HTMLTextAreaElement; - + // Validar tamanho da mensagem if (mensagem.length > MAX_MENSAGEM_LENGTH) { mensagemMuitoLonga = true; @@ -180,7 +192,7 @@ } else { mensagemMuitoLonga = false; } - + if (textarea) { textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; @@ -237,7 +249,7 @@ async function handleEnviar() { const texto = mensagem.trim(); if (!texto || enviando) return; - + // Validar tamanho antes de enviar if (texto.length > MAX_MENSAGEM_LENGTH) { alert(`Mensagem muito longa. O limite é de ${MAX_MENSAGEM_LENGTH} caracteres.`); @@ -258,10 +270,70 @@ } } + // Verificar se a conversa tem criptografia E2E e criptografar mensagem se necessário + const conversaTemE2E = temCriptografiaE2E?.data ?? false; + let conteudoParaEnviar = texto; + let criptografado = false; + let iv: string | undefined; + let keyId: string | undefined; + + if (conversaTemE2E) { + try { + // Tentar obter chave do store primeiro + let encryptionKey = obterChaveCriptografia(conversaId); + + // Se não estiver no store, tentar carregar do localStorage + if (!encryptionKey) { + encryptionKey = await loadEncryptionKey(conversaId); + if (encryptionKey) { + armazenarChaveCriptografia(conversaId, encryptionKey); + } + } + + // Se ainda não tiver chave, tentar obter do servidor + if (!encryptionKey) { + const chaveDoServidor = await client.query(api.chat.obterChaveCriptografia, { + conversaId + }); + + if (chaveDoServidor?.chaveCompartilhada) { + // Importar chave do servidor (assumindo que está em formato exportado) + // Nota: Em produção, a chave do servidor deve ser criptografada com chave pública do usuário + // Por enquanto, vamos assumir que a chave já está descriptografada no cliente + const { importKey } = await import('$lib/utils/e2eEncryption'); + encryptionKey = await importKey(chaveDoServidor.chaveCompartilhada); + + // Armazenar chave localmente + const keyData = await exportKey(encryptionKey); + storeEncryptionKey(conversaId, keyData, chaveDoServidor.keyId); + armazenarChaveCriptografia(conversaId, encryptionKey); + } + } + + if (encryptionKey) { + // Criptografar mensagem + const encrypted: EncryptedMessage = await encryptMessage(texto, encryptionKey); + conteudoParaEnviar = encrypted.encryptedContent; + iv = encrypted.iv; + keyId = encrypted.keyId; + criptografado = true; + } else { + console.warn( + '⚠️ [MessageInput] Criptografia E2E habilitada mas chave não encontrada. Enviando sem criptografia.' + ); + } + } catch (error) { + console.error('❌ [MessageInput] Erro ao criptografar mensagem:', error); + alert('Erro ao criptografar mensagem. Tentando enviar sem criptografia...'); + // Continuar sem criptografia em caso de erro + } + } + console.log('📤 [MessageInput] Enviando mensagem:', { conversaId, - conteudo: texto, + conteudo: criptografado ? '[CRIPTOGRAFADO]' : texto, tipo: 'texto', + criptografado, respostaPara: mensagemRespondendo?.id, mencoes: mencoesIds }); @@ -270,10 +342,13 @@ enviando = true; const result = await client.mutation(api.chat.enviarMensagem, { conversaId, - conteudo: texto, + conteudo: conteudoParaEnviar, tipo: 'texto', respostaPara: mensagemRespondendo?.id, - mencoes: mencoesIds.length > 0 ? mencoesIds : undefined + mencoes: mencoesIds.length > 0 ? mencoesIds : undefined, + criptografado: criptografado ? true : undefined, + iv: iv, + keyId: keyId }); console.log('✅ [MessageInput] Mensagem enviada com sucesso! ID:', result); @@ -309,7 +384,7 @@ const customEvent = e as CustomEvent<{ mensagemId: Id<'mensagens'> }>; // Buscar informações da mensagem para exibir preview client.query(api.chat.obterMensagens, { conversaId, limit: 100 }).then((mensagens) => { - const msg = (mensagens as MensagemComRemetente[]).find( + const msg = (mensagens as unknown as { mensagens: MensagemComRemetente[] }).mensagens.find( (m) => m._id === customEvent.detail.mensagemId ); if (msg) { @@ -338,19 +413,19 @@ // Navegar dropdown de menções if (showMentionsDropdown && participantesFiltrados().length > 0) { const participantes = participantesFiltrados(); - + if (e.key === 'ArrowDown') { e.preventDefault(); selectedMentionIndex = Math.min(selectedMentionIndex + 1, participantes.length - 1); return; } - + if (e.key === 'ArrowUp') { e.preventDefault(); selectedMentionIndex = Math.max(selectedMentionIndex - 1, 0); return; } - + if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); if (participantes[selectedMentionIndex]) { @@ -358,7 +433,7 @@ } return; } - + if (e.key === 'Escape') { e.preventDefault(); showMentionsDropdown = false; @@ -381,7 +456,9 @@ // Validar tamanho if (file.size > MAX_FILE_SIZE) { - alert(`Arquivo muito grande. O tamanho máximo é ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB.`); + alert( + `Arquivo muito grande. O tamanho máximo é ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB.` + ); input.value = ''; return; } @@ -428,6 +505,58 @@ // Sanitizar nome do arquivo (remover caracteres perigosos) const nomeSanitizado = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); + // Verificar se a conversa tem criptografia E2E e criptografar arquivo se necessário + const conversaTemE2E = temCriptografiaE2E?.data ?? false; + let arquivoParaUpload: Blob = file; + let arquivoCriptografado = false; + let arquivoIv: string | undefined; + let arquivoKeyId: string | undefined; + + if (conversaTemE2E) { + try { + // Tentar obter chave de criptografia + let encryptionKey = obterChaveCriptografia(conversaId); + + if (!encryptionKey) { + encryptionKey = await loadEncryptionKey(conversaId); + if (encryptionKey) { + armazenarChaveCriptografia(conversaId, encryptionKey); + } + } + + if (!encryptionKey) { + const chaveDoServidor = await client.query(api.chat.obterChaveCriptografia, { + conversaId + }); + + if (chaveDoServidor?.chaveCompartilhada) { + const { importKey } = await import('$lib/utils/e2eEncryption'); + encryptionKey = await importKey(chaveDoServidor.chaveCompartilhada); + + const keyData = await exportKey(encryptionKey); + storeEncryptionKey(conversaId, keyData, chaveDoServidor.keyId); + armazenarChaveCriptografia(conversaId, encryptionKey); + } + } + + if (encryptionKey) { + // Criptografar arquivo + const encrypted = await encryptFile(file, encryptionKey); + arquivoParaUpload = encrypted.encryptedBlob; + arquivoIv = encrypted.iv; + arquivoKeyId = encrypted.keyId; + arquivoCriptografado = true; + } else { + console.warn( + '⚠️ [MessageInput] Criptografia E2E habilitada mas chave não encontrada. Enviando arquivo sem criptografia.' + ); + } + } catch (error) { + console.error('❌ [MessageInput] Erro ao criptografar arquivo:', error); + alert('Erro ao criptografar arquivo. Tentando enviar sem criptografia...'); + } + } + try { uploadingFile = true; uploadProgress = 0; @@ -470,8 +599,12 @@ }); xhr.open('POST', uploadUrl); - xhr.setRequestHeader('Content-Type', file.type); - xhr.send(file); + // Se arquivo foi criptografado, usar tipo genérico + xhr.setRequestHeader( + 'Content-Type', + arquivoCriptografado ? 'application/octet-stream' : file.type + ); + xhr.send(arquivoParaUpload); }); const storageId = await uploadPromise; @@ -482,10 +615,14 @@ conversaId, conteudo: tipo === 'imagem' ? '' : nomeSanitizado, tipo, - arquivoId: storageId, + arquivoId: storageId as Id<'_storage'>, arquivoNome: nomeSanitizado, arquivoTamanho: file.size, - arquivoTipo: file.type + arquivoTipo: file.type, + // Campos de criptografia E2E para arquivos + criptografado: arquivoCriptografado ? true : undefined, + iv: arquivoIv, + keyId: arquivoKeyId }); // Limpar input @@ -558,15 +695,15 @@ /> {/if} - + {#if uploadingFile && uploadProgress > 0}
@@ -605,7 +742,7 @@ id="emoji-picker" >
- {#each emojis as emoji} + {#each emojis as emoji, index (emoji)}
- + @@ -4758,13 +4703,20 @@ - {#each Object.values(grupo.registrosPorData) as grupoData, dataIndex} + {#each Object.values(grupo.registrosPorData) as grupoData, dataIndex (grupoData.data)} {@const totalRegistros = grupoData.registros.length} {@const dataFormatada = formatarDataDDMMAAAA(grupoData.data)} - {@const saldosParciais = calcularSaldosParciais(grupoData.registros)} + {@const saldosParciais = calcularSaldosParciais( + grupoData.registros.map((r) => ({ + tipo: r.tipo, + hora: r.hora, + minuto: r.minuto, + _id: r._id + })) + )} {@const isUltimoDia = dataIndex === Object.values(grupo.registrosPorData).length - 1} - {#each grupoData.registros as registro, index} + {#each grupoData.registros as registro, index (registro._id)} {@const saldoParcial = saldosParciais.get(index)} e.target === e.currentTarget && fecharModalDetalhes()} > + - + {/if} diff --git a/apps/web/src/routes/+error.svelte b/apps/web/src/routes/+error.svelte index 49567fe..194fb91 100644 --- a/apps/web/src/routes/+error.svelte +++ b/apps/web/src/routes/+error.svelte @@ -75,3 +75,6 @@ + + + diff --git a/packages/backend/convex/autenticacao.ts b/packages/backend/convex/autenticacao.ts index 29abe5c..c5a838c 100644 --- a/packages/backend/convex/autenticacao.ts +++ b/packages/backend/convex/autenticacao.ts @@ -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 diff --git a/packages/backend/convex/chat.ts b/packages/backend/convex/chat.ts index 3ab37ed..ff69336 100644 --- a/packages/backend/convex/chat.ts +++ b/packages/backend/convex/chat.ts @@ -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; + } +}); diff --git a/packages/backend/convex/tables/chat.ts b/packages/backend/convex/tables/chat.ts index 5230eb3..72ee5d2 100644 --- a/packages/backend/convex/tables/chat.ts +++ b/packages/backend/convex/tables/chat.ts @@ -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']) };