From e4dbeddfec19d24a17760724f3e9a0ed430ec008 Mon Sep 17 00:00:00 2001 From: Akshay Date: Mon, 3 Nov 2025 17:45:17 +0530 Subject: [PATCH] feat(messages): Add end-to-end message encryption and decryption - Implement client-side message encryption and decryption APIs - Add encryption and decryption routes for secure message handling - Update message services to support encrypted message storage and retrieval - Modify hooks and services to transparently handle message encryption - Create utility functions for generating and managing encryption keys - Add scripts for encryption key generation and testing - Ensure secure message content protection during transmission and storage Enhances message privacy and security by implementing robust encryption mechanisms for all user messages. --- app/api/messages/decrypt/route.ts | 25 ++++++++ app/api/messages/encrypt/route.ts | 25 ++++++++ hooks/useMessages.ts | 12 +++- lib/services/conversationService.ts | 27 ++++++++ lib/services/messageService.ts | 75 ++++++++++++++++++++-- lib/utils/encryption.ts | 97 +++++++++++++++++++++++++++++ scripts/generate-encryption-key.js | 49 +++++++++++++++ scripts/test-encryption.js | 63 +++++++++++++++++++ 8 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 app/api/messages/decrypt/route.ts create mode 100644 app/api/messages/encrypt/route.ts create mode 100644 lib/utils/encryption.ts create mode 100644 scripts/generate-encryption-key.js create mode 100644 scripts/test-encryption.js diff --git a/app/api/messages/decrypt/route.ts b/app/api/messages/decrypt/route.ts new file mode 100644 index 00000000..76f822e5 --- /dev/null +++ b/app/api/messages/decrypt/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server' +import { decryptMessage } from '@/lib/utils/encryption' + +export async function POST(request: NextRequest) { + try { + const { encrypted } = await request.json() + + if (!encrypted || typeof encrypted !== 'string') { + return NextResponse.json( + { error: 'Encrypted content is required' }, + { status: 400 } + ) + } + + const decrypted = decryptMessage(encrypted) + + return NextResponse.json({ decrypted }) + } catch (error) { + console.error('Decryption API error:', error) + return NextResponse.json( + { error: 'Failed to decrypt message' }, + { status: 500 } + ) + } +} diff --git a/app/api/messages/encrypt/route.ts b/app/api/messages/encrypt/route.ts new file mode 100644 index 00000000..7cbd8886 --- /dev/null +++ b/app/api/messages/encrypt/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server' +import { encryptMessage } from '@/lib/utils/encryption' + +export async function POST(request: NextRequest) { + try { + const { content } = await request.json() + + if (!content || typeof content !== 'string') { + return NextResponse.json( + { error: 'Content is required' }, + { status: 400 } + ) + } + + const encrypted = encryptMessage(content) + + return NextResponse.json({ encrypted }) + } catch (error) { + console.error('Encryption API error:', error) + return NextResponse.json( + { error: 'Failed to encrypt message' }, + { status: 500 } + ) + } +} diff --git a/hooks/useMessages.ts b/hooks/useMessages.ts index 3524502d..2e3a98d7 100644 --- a/hooks/useMessages.ts +++ b/hooks/useMessages.ts @@ -70,11 +70,21 @@ export function useMessages(conversationId: string | null) { .single() if (data) { + // Decrypt the message content + const decryptResponse = await fetch('/api/messages/decrypt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ encrypted: data.content }) + }) + + const { decrypted } = await decryptResponse.json() + const decryptedMessage = { ...data, content: decrypted } + setMessages(prev => { // Avoid duplicates const exists = prev.some(msg => msg.id === data.id) if (exists) return prev - return [...prev, data as Message] + return [...prev, decryptedMessage as Message] }) // Mark as read await messageService.markAsRead(conversationId) diff --git a/lib/services/conversationService.ts b/lib/services/conversationService.ts index e84034cf..6899e16f 100644 --- a/lib/services/conversationService.ts +++ b/lib/services/conversationService.ts @@ -6,6 +6,29 @@ export class ConversationService { return createClient() } + // Decrypt message content via API + private async decryptContent(encrypted: string | null): Promise { + if (!encrypted) return null + + try { + const response = await fetch('/api/messages/decrypt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ encrypted }) + }) + + if (!response.ok) { + return encrypted // Return encrypted if decryption fails + } + + const { decrypted } = await response.json() + return decrypted + } catch (error) { + console.error('Error decrypting last message:', error) + return encrypted // Return encrypted if error occurs + } + } + // Get all conversations for current user async getConversations(): Promise { const supabase = this.getSupabaseClient() @@ -73,8 +96,12 @@ export class ConversationService { .neq('sender_id', user.id) .gt('created_at', item.last_read_at || '1970-01-01') + // Decrypt last message content + const decryptedLastMessage = await this.decryptContent(conversation.last_message_content) + return { ...conversation, + last_message_content: decryptedLastMessage, participants: participants || [], other_user: otherUser, unread_count: unreadMessages?.length || 0 diff --git a/lib/services/messageService.ts b/lib/services/messageService.ts index 51675b36..d87451d9 100644 --- a/lib/services/messageService.ts +++ b/lib/services/messageService.ts @@ -6,6 +6,48 @@ export class MessageService { return createClient() } + // Encrypt message content via API + private async encryptContent(content: string): Promise { + try { + const response = await fetch('/api/messages/encrypt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }) + }) + + if (!response.ok) { + throw new Error('Encryption failed') + } + + const { encrypted } = await response.json() + return encrypted + } catch (error) { + console.error('Error encrypting message:', error) + throw new Error('Failed to encrypt message') + } + } + + // Decrypt message content via API + private async decryptContent(encrypted: string): Promise { + try { + const response = await fetch('/api/messages/decrypt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ encrypted }) + }) + + if (!response.ok) { + throw new Error('Decryption failed') + } + + const { decrypted } = await response.json() + return decrypted + } catch (error) { + console.error('Error decrypting message:', error) + return '[Message could not be decrypted]' + } + } + // Get messages for a conversation async getMessages(conversationId: string): Promise { const supabase = this.getSupabaseClient() @@ -31,7 +73,15 @@ export class MessageService { throw new Error(`Failed to fetch messages: ${error.message}`) } - return data as Message[] + // Decrypt all messages + const decryptedMessages = await Promise.all( + data.map(async (message) => ({ + ...message, + content: await this.decryptContent(message.content) + })) + ) + + return decryptedMessages as Message[] } // Send a message @@ -43,10 +93,13 @@ export class MessageService { throw new Error('User not authenticated') } + // Encrypt the message content before sending + const encryptedContent = await this.encryptContent(data.content) + const messageData = { conversation_id: data.conversation_id, sender_id: user.id, - content: data.content, + content: encryptedContent, reply_to_id: data.reply_to_id || null, attachments: data.attachments || null } @@ -71,7 +124,13 @@ export class MessageService { throw new Error(`Failed to send message: ${error.message}`) } - return message as Message + // Decrypt the content before returning + const decryptedMessage = { + ...message, + content: await this.decryptContent(message.content) + } + + return decryptedMessage as Message } // Mark messages as read @@ -96,11 +155,14 @@ export class MessageService { async deleteMessage(messageId: string): Promise { const supabase = this.getSupabaseClient() + // Encrypt the deletion message + const encryptedDeletedMessage = await this.encryptContent('This message was deleted') + const { error } = await supabase .from('messages') .update({ is_deleted: true, - content: 'This message was deleted', + content: encryptedDeletedMessage, updated_at: new Date().toISOString() }) .eq('id', messageId) @@ -115,10 +177,13 @@ export class MessageService { async editMessage(messageId: string, newContent: string): Promise { const supabase = this.getSupabaseClient() + // Encrypt the new content + const encryptedContent = await this.encryptContent(newContent) + const { error } = await supabase .from('messages') .update({ - content: newContent, + content: encryptedContent, is_edited: true, updated_at: new Date().toISOString() }) diff --git a/lib/utils/encryption.ts b/lib/utils/encryption.ts new file mode 100644 index 00000000..a6021c66 --- /dev/null +++ b/lib/utils/encryption.ts @@ -0,0 +1,97 @@ +import crypto from 'crypto' + +const ALGORITHM = 'aes-256-gcm' +const IV_LENGTH = 16 + +/** + * Get encryption key from environment variable + * The key should be a 64-character hex string (32 bytes) + */ +function getEncryptionKey(): Buffer { + const key = process.env.MESSAGE_ENCRYPTION_KEY + + if (!key) { + throw new Error('MESSAGE_ENCRYPTION_KEY environment variable is not set') + } + + if (key.length !== 64) { + throw new Error('MESSAGE_ENCRYPTION_KEY must be 64 hex characters (32 bytes)') + } + + return Buffer.from(key, 'hex') +} + +/** + * Encrypt a message using AES-256-GCM + * @param text - Plain text message to encrypt + * @returns Encrypted message in format: iv:authTag:encryptedData + */ +export function encryptMessage(text: string): string { + try { + const key = getEncryptionKey() + const iv = crypto.randomBytes(IV_LENGTH) + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + + let encrypted = cipher.update(text, 'utf8', 'hex') + encrypted += cipher.final('hex') + + const authTag = cipher.getAuthTag() + + // Return format: iv:authTag:encryptedData + return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}` + } catch (error) { + console.error('Encryption error:', error) + throw new Error('Failed to encrypt message') + } +} + +/** + * Decrypt a message using AES-256-GCM + * @param encryptedText - Encrypted message in format: iv:authTag:encryptedData + * @returns Decrypted plain text message + */ +export function decryptMessage(encryptedText: string): string { + try { + const key = getEncryptionKey() + const parts = encryptedText.split(':') + + if (parts.length !== 3) { + throw new Error('Invalid encrypted message format') + } + + const [ivHex, authTagHex, encrypted] = parts + + const decipher = crypto.createDecipheriv( + ALGORITHM, + key, + Buffer.from(ivHex, 'hex') + ) + + decipher.setAuthTag(Buffer.from(authTagHex, 'hex')) + + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + return decrypted + } catch (error) { + console.error('Decryption error:', error) + // Return a placeholder for corrupted/invalid encrypted messages + return '[Message could not be decrypted]' + } +} + +/** + * Generate a new encryption key (for initial setup) + * Run this once and store the result in your .env file + */ +export function generateEncryptionKey(): string { + return crypto.randomBytes(32).toString('hex') +} + +/** + * Check if a message is encrypted (has the expected format) + */ +export function isEncrypted(text: string): boolean { + const parts = text.split(':') + return parts.length === 3 && parts[0].length === 32 && parts[1].length === 32 +} diff --git a/scripts/generate-encryption-key.js b/scripts/generate-encryption-key.js new file mode 100644 index 00000000..1234c048 --- /dev/null +++ b/scripts/generate-encryption-key.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +/** + * Generate a secure encryption key for message encryption + * Run this script once and add the key to your .env.local file + */ + +const crypto = require('crypto') +const fs = require('fs') +const path = require('path') + +// Generate a 32-byte (256-bit) key +const key = crypto.randomBytes(32).toString('hex') + +console.log('\nšŸ” Message Encryption Key Generated!\n') +console.log('Copy this key to your .env.local file:\n') +console.log(`MESSAGE_ENCRYPTION_KEY=${key}\n`) +console.log('āš ļø IMPORTANT:') +console.log('1. Keep this key SECRET and SECURE') +console.log('2. Never commit it to version control') +console.log('3. Back it up somewhere safe') +console.log('4. If you lose it, all encrypted messages will be unreadable\n') + +// Try to append to .env.local +const envPath = path.join(process.cwd(), '.env.local') + +try { + let envContent = '' + + if (fs.existsSync(envPath)) { + envContent = fs.readFileSync(envPath, 'utf8') + + // Check if key already exists + if (envContent.includes('MESSAGE_ENCRYPTION_KEY=')) { + console.log('āš ļø MESSAGE_ENCRYPTION_KEY already exists in .env.local') + console.log('If you want to replace it, do so manually.\n') + process.exit(0) + } + } + + // Append the key + const newLine = envContent.endsWith('\n') ? '' : '\n' + fs.appendFileSync(envPath, `${newLine}\n# Message Encryption Key (DO NOT SHARE)\nMESSAGE_ENCRYPTION_KEY=${key}\n`) + + console.log('āœ… Key has been added to .env.local\n') +} catch (error) { + console.log('āŒ Could not write to .env.local automatically') + console.log('Please add the key manually to your .env.local file\n') +} diff --git a/scripts/test-encryption.js b/scripts/test-encryption.js new file mode 100644 index 00000000..f40746c8 --- /dev/null +++ b/scripts/test-encryption.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node + +/** + * Test script to verify message encryption is working + * Run: node scripts/test-encryption.js + */ + +async function testEncryption() { + console.log('\nšŸ” Testing Message Encryption...\n') + + const testMessage = 'Hello, this is a secret message! šŸ”’' + console.log('Original message:', testMessage) + + try { + // Test encryption + console.log('\n1. Encrypting message...') + const encryptResponse = await fetch('http://localhost:3000/api/messages/encrypt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: testMessage }) + }) + + if (!encryptResponse.ok) { + throw new Error('Encryption failed') + } + + const { encrypted } = await encryptResponse.json() + console.log('āœ… Encrypted:', encrypted) + + // Test decryption + console.log('\n2. Decrypting message...') + const decryptResponse = await fetch('http://localhost:3000/api/messages/decrypt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ encrypted }) + }) + + if (!decryptResponse.ok) { + throw new Error('Decryption failed') + } + + const { decrypted } = await decryptResponse.json() + console.log('āœ… Decrypted:', decrypted) + + // Verify + console.log('\n3. Verifying...') + if (decrypted === testMessage) { + console.log('āœ… SUCCESS! Encryption and decryption working correctly!\n') + } else { + console.log('āŒ FAILED! Decrypted message does not match original\n') + process.exit(1) + } + } catch (error) { + console.error('\nāŒ Error:', error.message) + console.log('\nMake sure:') + console.log('1. Your dev server is running (npm run dev)') + console.log('2. MESSAGE_ENCRYPTION_KEY is set in .env.local') + console.log('3. You have restarted the server after adding the key\n') + process.exit(1) + } +} + +testEncryption()