Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app/api/messages/decrypt/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
)
}
}
25 changes: 25 additions & 0 deletions app/api/messages/encrypt/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
)
}
}
12 changes: 11 additions & 1 deletion hooks/useMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions lib/services/conversationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ export class ConversationService {
return createClient()
}

// Decrypt message content via API
private async decryptContent(encrypted: string | null): Promise<string | null> {
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<ConversationWithDetails[]> {
const supabase = this.getSupabaseClient()
Expand Down Expand Up @@ -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
Expand Down
75 changes: 70 additions & 5 deletions lib/services/messageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,48 @@ export class MessageService {
return createClient()
}

// Encrypt message content via API
private async encryptContent(content: string): Promise<string> {
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<string> {
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<Message[]> {
const supabase = this.getSupabaseClient()
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -96,11 +155,14 @@ export class MessageService {
async deleteMessage(messageId: string): Promise<void> {
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)
Expand All @@ -115,10 +177,13 @@ export class MessageService {
async editMessage(messageId: string, newContent: string): Promise<void> {
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()
})
Expand Down
97 changes: 97 additions & 0 deletions lib/utils/encryption.ts
Original file line number Diff line number Diff line change
@@ -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
}
49 changes: 49 additions & 0 deletions scripts/generate-encryption-key.js
Original file line number Diff line number Diff line change
@@ -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')
}
Loading
Loading