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:
301
apps/web/src/lib/utils/e2eEncryption.ts
Normal file
301
apps/web/src/lib/utils/e2eEncryption.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Utilitário de Criptografia End-to-End (E2E) para mensagens do chat
|
||||
* Usa Web Crypto API com AES-GCM para criptografia simétrica
|
||||
*/
|
||||
|
||||
export interface EncryptionKey {
|
||||
key: CryptoKey;
|
||||
keyId: string; // Identificador único da chave
|
||||
createdAt: number; // Timestamp de criação
|
||||
}
|
||||
|
||||
export interface EncryptedMessage {
|
||||
encryptedContent: string; // Base64
|
||||
iv: string; // Base64
|
||||
keyId: string; // Identificador da chave usada
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera uma chave de criptografia AES-GCM de 256 bits
|
||||
*/
|
||||
export async function generateEncryptionKey(): Promise<EncryptionKey> {
|
||||
const key = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 256
|
||||
},
|
||||
true, // extractable
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// Gerar um ID único para a chave
|
||||
const keyId = crypto.randomUUID();
|
||||
|
||||
return {
|
||||
key,
|
||||
keyId,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporta uma chave para formato JSON (armazenamento)
|
||||
*/
|
||||
export async function exportKey(key: CryptoKey): Promise<string> {
|
||||
const exported = await crypto.subtle.exportKey('raw', key);
|
||||
const exportedArray = new Uint8Array(exported);
|
||||
return btoa(String.fromCharCode(...exportedArray));
|
||||
}
|
||||
|
||||
/**
|
||||
* Importa uma chave de formato JSON (armazenamento)
|
||||
*/
|
||||
export async function importKey(keyData: string): Promise<CryptoKey> {
|
||||
const keyArray = Uint8Array.from(atob(keyData), (c) => c.charCodeAt(0));
|
||||
return await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyArray,
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 256
|
||||
},
|
||||
true, // extractable
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Criptografa uma mensagem usando uma chave
|
||||
*/
|
||||
export async function encryptMessage(
|
||||
message: string,
|
||||
key: CryptoKey
|
||||
): Promise<EncryptedMessage> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(message);
|
||||
|
||||
// Gerar IV (Initialization Vector) aleatório de 12 bytes para AES-GCM
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// Criptografar
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
},
|
||||
key,
|
||||
data
|
||||
);
|
||||
|
||||
// Converter para base64 para armazenamento
|
||||
const encryptedArray = new Uint8Array(encrypted);
|
||||
const encryptedBase64 = btoa(String.fromCharCode(...encryptedArray));
|
||||
const ivBase64 = btoa(String.fromCharCode(...iv));
|
||||
|
||||
// Gerar keyId (usaremos o hash da chave exportada como identificador)
|
||||
const keyData = await exportKey(key);
|
||||
const keyId = await hashString(keyData);
|
||||
|
||||
return {
|
||||
encryptedContent: encryptedBase64,
|
||||
iv: ivBase64,
|
||||
keyId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Criptografa um arquivo (Blob) usando uma chave
|
||||
*/
|
||||
export async function encryptFile(
|
||||
file: Blob,
|
||||
key: CryptoKey
|
||||
): Promise<{ encryptedBlob: Blob; iv: string; keyId: string }> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const data = new Uint8Array(arrayBuffer);
|
||||
|
||||
// Gerar IV (Initialization Vector) aleatório de 12 bytes para AES-GCM
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// Criptografar
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
},
|
||||
key,
|
||||
data
|
||||
);
|
||||
|
||||
// Converter para Blob
|
||||
const encryptedBlob = new Blob([encrypted], { type: 'application/octet-stream' });
|
||||
|
||||
// Converter IV para base64
|
||||
const ivBase64 = btoa(String.fromCharCode(...iv));
|
||||
|
||||
// Gerar keyId
|
||||
const keyData = await exportKey(key);
|
||||
const keyId = await hashString(keyData);
|
||||
|
||||
return {
|
||||
encryptedBlob,
|
||||
iv: ivBase64,
|
||||
keyId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptografa um arquivo (Blob) usando uma chave
|
||||
*/
|
||||
export async function decryptFile(
|
||||
encryptedBlob: Blob,
|
||||
iv: string,
|
||||
key: CryptoKey
|
||||
): Promise<Blob> {
|
||||
try {
|
||||
// Decodificar base64 do IV
|
||||
const ivArray = Uint8Array.from(atob(iv), (c) => c.charCodeAt(0));
|
||||
|
||||
// Ler dados criptografados
|
||||
const encryptedArrayBuffer = await encryptedBlob.arrayBuffer();
|
||||
const encrypted = new Uint8Array(encryptedArrayBuffer);
|
||||
|
||||
// Descriptografar
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: ivArray
|
||||
},
|
||||
key,
|
||||
encrypted
|
||||
);
|
||||
|
||||
// Converter para Blob
|
||||
return new Blob([decrypted]);
|
||||
} catch (error) {
|
||||
console.error('Erro ao descriptografar arquivo:', error);
|
||||
throw new Error('Falha ao descriptografar arquivo. A chave pode estar incorreta.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptografa uma mensagem usando uma chave
|
||||
*/
|
||||
export async function decryptMessage(
|
||||
encryptedMessage: EncryptedMessage,
|
||||
key: CryptoKey
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Decodificar base64
|
||||
const encryptedArray = Uint8Array.from(
|
||||
atob(encryptedMessage.encryptedContent),
|
||||
(c) => c.charCodeAt(0)
|
||||
);
|
||||
const iv = Uint8Array.from(atob(encryptedMessage.iv), (c) => c.charCodeAt(0));
|
||||
|
||||
// Descriptografar
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
},
|
||||
key,
|
||||
encryptedArray
|
||||
);
|
||||
|
||||
// Converter para string
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
} catch (error) {
|
||||
console.error('Erro ao descriptografar mensagem:', error);
|
||||
throw new Error('Falha ao descriptografar mensagem. A chave pode estar incorreta.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera hash SHA-256 de uma string (para usar como keyId)
|
||||
*/
|
||||
async function hashString(str: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(str);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = new Uint8Array(hashBuffer);
|
||||
// Retornar apenas os primeiros 16 bytes como hex para keyId mais curto
|
||||
return Array.from(hashArray.slice(0, 16))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Armazena uma chave de criptografia no localStorage
|
||||
*/
|
||||
export function storeEncryptionKey(conversaId: string, keyData: string, keyId: string): void {
|
||||
try {
|
||||
const storageKey = `e2e_key_${conversaId}`;
|
||||
const keyInfo = {
|
||||
keyData,
|
||||
keyId,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
localStorage.setItem(storageKey, JSON.stringify(keyInfo));
|
||||
} catch (error) {
|
||||
console.error('Erro ao armazenar chave de criptografia:', error);
|
||||
throw new Error('Falha ao armazenar chave de criptografia');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera uma chave de criptografia do localStorage
|
||||
*/
|
||||
export function getStoredEncryptionKey(conversaId: string): {
|
||||
keyData: string;
|
||||
keyId: string;
|
||||
createdAt: number;
|
||||
} | null {
|
||||
try {
|
||||
const storageKey = `e2e_key_${conversaId}`;
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(stored) as { keyData: string; keyId: string; createdAt: number };
|
||||
} catch (error) {
|
||||
console.error('Erro ao recuperar chave de criptografia:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove uma chave de criptografia do localStorage
|
||||
*/
|
||||
export function removeStoredEncryptionKey(conversaId: string): void {
|
||||
try {
|
||||
const storageKey = `e2e_key_${conversaId}`;
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
console.error('Erro ao remover chave de criptografia:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se uma conversa tem criptografia E2E habilitada
|
||||
*/
|
||||
export function hasEncryptionKey(conversaId: string): boolean {
|
||||
return getStoredEncryptionKey(conversaId) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega e importa uma chave de criptografia do localStorage
|
||||
*/
|
||||
export async function loadEncryptionKey(conversaId: string): Promise<CryptoKey | null> {
|
||||
const stored = getStoredEncryptionKey(conversaId);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await importKey(stored.keyData);
|
||||
} catch (error) {
|
||||
console.error('Erro ao importar chave de criptografia:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,3 +436,6 @@ export function adicionarRodape(doc: jsPDF): void {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user