diff --git a/src/app/api/chat/keys/route.ts b/src/app/api/chat/keys/route.ts new file mode 100644 index 0000000..4175a62 --- /dev/null +++ b/src/app/api/chat/keys/route.ts @@ -0,0 +1,78 @@ +/** + * Chat Keys API + * + * GET: Get current user's chat keys (public key + encrypted private key backup) + * POST: Register/update chat keys with encrypted private key backup + * + * The private key is encrypted CLIENT-SIDE with the user's password before + * being sent to the server. The server cannot decrypt it. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, users } from '@/db'; +import { eq } from 'drizzle-orm'; +import { getSession } from '@/lib/auth'; + +export async function GET() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await db.query.users.findFirst({ + where: eq(users.id, session.user.id), + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + return NextResponse.json({ + chatPublicKey: user.chatPublicKey, + // Return encrypted private key so client can decrypt with password + chatPrivateKeyEncrypted: user.chatPrivateKeyEncrypted, + hasKeys: !!user.chatPublicKey, + }); + } catch (error) { + console.error('Get chat keys error:', error); + return NextResponse.json({ error: 'Failed to get chat keys' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { chatPublicKey, chatPrivateKeyEncrypted } = await request.json(); + + if (!chatPublicKey || typeof chatPublicKey !== 'string') { + return NextResponse.json({ error: 'chatPublicKey required' }, { status: 400 }); + } + + // Validate it looks like a base64 SPKI key + if (chatPublicKey.length < 50 || chatPublicKey.length > 500) { + return NextResponse.json({ error: 'Invalid public key format' }, { status: 400 }); + } + + // chatPrivateKeyEncrypted should be a JSON string with encrypted data + if (chatPrivateKeyEncrypted && typeof chatPrivateKeyEncrypted !== 'string') { + return NextResponse.json({ error: 'Invalid encrypted private key format' }, { status: 400 }); + } + + await db.update(users) + .set({ + chatPublicKey, + chatPrivateKeyEncrypted: chatPrivateKeyEncrypted || null, + }) + .where(eq(users.id, session.user.id)); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Register chat keys error:', error); + return NextResponse.json({ error: 'Failed to register chat keys' }, { status: 500 }); + } +} diff --git a/src/app/api/swarm/chat/messages/route.ts b/src/app/api/swarm/chat/messages/route.ts index 21f52b5..ac5bc3b 100644 --- a/src/app/api/swarm/chat/messages/route.ts +++ b/src/app/api/swarm/chat/messages/route.ts @@ -71,10 +71,23 @@ export async function GET(request: NextRequest) { const messagesWithDecryption = messages.map((msg) => { const isSentByMe = msg.senderHandle === session.user.handle; + // Return the appropriate encrypted content: + // - For sent messages: use senderEncryptedContent (encrypted with sender's key) + // - For received messages: use encryptedContent (encrypted with recipient's key) + const encryptedForViewer = isSentByMe + ? (msg.senderEncryptedContent || msg.encryptedContent) + : msg.encryptedContent; + return { - ...msg, - // Only include encrypted content - client will decrypt - content: undefined, // Remove plaintext + id: msg.id, + senderHandle: msg.senderHandle, + senderDisplayName: msg.senderDisplayName, + senderAvatarUrl: msg.senderAvatarUrl, + senderPublicKey: msg.senderChatPublicKey, // For E2E decryption + encryptedContent: encryptedForViewer, + deliveredAt: msg.deliveredAt, + readAt: msg.readAt, + createdAt: msg.createdAt, isSentByMe, }; }); diff --git a/src/app/api/swarm/chat/send/route.ts b/src/app/api/swarm/chat/send/route.ts index 1738139..7ed0bf3 100644 --- a/src/app/api/swarm/chat/send/route.ts +++ b/src/app/api/swarm/chat/send/route.ts @@ -14,7 +14,13 @@ import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types'; const sendMessageSchema = z.object({ recipientHandle: z.string(), - content: z.string().min(1).max(5000), + // For E2E encryption: client sends pre-encrypted content + encryptedContent: z.string().optional(), + senderPublicKey: z.string().optional(), // ECDH public key for decryption + // Legacy: server-side encryption (will be removed) + content: z.string().min(1).max(5000).optional(), +}).refine(data => data.encryptedContent || data.content, { + message: 'Either encryptedContent or content is required', }); export async function POST(request: NextRequest) { @@ -152,14 +158,29 @@ export async function POST(request: NextRequest) { recipientPublicKey = recipientUser.publicKey; } - // Encrypt the message with recipient's public key - console.log('[Chat Send] Encrypting message...'); + // Handle encryption - either client-side (E2E) or server-side (legacy) let encryptedContent: string; - try { - encryptedContent = encryptMessage(data.content, recipientPublicKey); - } catch (encError) { - console.error('[Chat Send] Encryption failed:', encError); - return NextResponse.json({ error: 'Encryption failed' }, { status: 500 }); + let senderEncryptedContent: string | null = null; + + if (data.encryptedContent) { + // E2E mode: client already encrypted the message + // Server cannot read the content - this is true E2E encryption + console.log('[Chat Send] Using client-side E2E encryption'); + encryptedContent = data.encryptedContent; + // Store sender's public key so recipient can decrypt + // Note: senderEncryptedContent not needed in E2E mode - client stores locally + } else if (data.content) { + // Legacy mode: server-side encryption (for backwards compatibility) + console.log('[Chat Send] Using server-side encryption (legacy)'); + try { + encryptedContent = encryptMessage(data.content, recipientPublicKey); + senderEncryptedContent = encryptMessage(data.content, sender.publicKey); + } catch (encError) { + console.error('[Chat Send] Encryption failed:', encError); + return NextResponse.json({ error: 'Encryption failed' }, { status: 500 }); + } + } else { + return NextResponse.json({ error: 'No message content provided' }, { status: 400 }); } // Get or create conversation @@ -176,7 +197,7 @@ export async function POST(request: NextRequest) { participant1Id: sender.id, participant2Handle: recipientHandle, lastMessageAt: new Date(), - lastMessagePreview: data.content.substring(0, 100), + lastMessagePreview: '🔒 Encrypted message', }).returning(); conversation = newConversation; } @@ -194,6 +215,8 @@ export async function POST(request: NextRequest) { senderAvatarUrl: sender.avatarUrl, senderNodeDomain: null, // Local sender encryptedContent, + senderEncryptedContent, + senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey, // For E2E decryption swarmMessageId, deliveredAt: isRemote ? null : new Date(), // Delivered immediately if local readAt: null, @@ -203,7 +226,7 @@ export async function POST(request: NextRequest) { await db.update(chatConversations) .set({ lastMessageAt: new Date(), - lastMessagePreview: data.content.substring(0, 100), + lastMessagePreview: '🔒 Encrypted message', updatedAt: new Date(), }) .where(eq(chatConversations.id, conversation.id)); diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts index 8aa2c64..17131fa 100644 --- a/src/app/api/users/[handle]/route.ts +++ b/src/app/api/users/[handle]/route.ts @@ -94,7 +94,8 @@ export async function GET(request: Request, context: RouteContext) { website: user.website, movedTo: user.movedTo, isBot: user.isBot, - publicKey: user.publicKey, // Needed for E2E encrypted chat + publicKey: user.publicKey, // RSA key for signing + chatPublicKey: user.chatPublicKey, // ECDH key for E2E chat }; // If this is a bot, include owner info diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index c0959fa..023b5e2 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -2,17 +2,14 @@ import { useState, useEffect, useRef } from 'react'; import { useAuth } from '@/lib/contexts/AuthContext'; -import { MessageCircle, Send, ArrowLeft, Search, Plus } from 'lucide-react'; +import { useChatEncryption } from '@/lib/hooks/useChatEncryption'; +import { MessageCircle, Send, ArrowLeft, Search, Plus, Lock, Shield, Key } from 'lucide-react'; import { formatFullHandle } from '@/lib/utils/handle'; import './chat.css'; interface Conversation { id: string; - participant2: { - handle: string; - displayName: string; - avatarUrl: string | null; - }; + participant2: { handle: string; displayName: string; avatarUrl: string | null; chatPublicKey: string | null; }; lastMessageAt: string; lastMessagePreview: string; unreadCount: number; @@ -23,7 +20,9 @@ interface Message { senderHandle: string; senderDisplayName?: string; senderAvatarUrl?: string; + senderPublicKey?: string; encryptedContent: string; + decryptedContent?: string; isSentByMe: boolean; deliveredAt?: string; readAt?: string; @@ -32,6 +31,7 @@ interface Message { export default function ChatPage() { const { user } = useAuth(); + const { keys, isReady, hasKeys, isRegistering, needsPasswordToRestore, generateAndRegisterKeys, restoreKeysWithPassword, encryptMessage, decryptMessage } = useChatEncryption(); const [conversations, setConversations] = useState([]); const [selectedConversation, setSelectedConversation] = useState(null); const [messages, setMessages] = useState([]); @@ -41,298 +41,48 @@ export default function ChatPage() { const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [recipientPublicKey, setRecipientPublicKey] = useState(null); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [password, setPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [isProcessingPassword, setIsProcessingPassword] = useState(false); const messagesEndRef = useRef(null); - useEffect(() => { - if (user) { - loadConversations(); - } - }, [user]); + useEffect(() => { if (user && hasKeys) loadConversations(); }, [user, hasKeys]); + useEffect(() => { if (selectedConversation && hasKeys) { loadMessages(selectedConversation.id); markAsRead(selectedConversation.id); fetchRecipientKey(selectedConversation.participant2.handle); } }, [selectedConversation, hasKeys]); + useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); - useEffect(() => { - if (selectedConversation) { - loadMessages(selectedConversation.id); - markAsRead(selectedConversation.id); - } - }, [selectedConversation]); + const fetchRecipientKey = async (handle: string) => { try { const res = await fetch(`/api/users/\${encodeURIComponent(handle)}`); const data = await res.json(); setRecipientPublicKey(data.user?.chatPublicKey || null); } catch { setRecipientPublicKey(null); } }; + const loadConversations = async () => { try { const res = await fetch('/api/swarm/chat/conversations'); const data = await res.json(); setConversations(data.conversations || []); } catch { /* ignore */ } finally { setLoading(false); } }; + const loadMessages = async (conversationId: string) => { try { const res = await fetch(`/api/swarm/chat/messages?conversationId=\${conversationId}`); const data = await res.json(); const decrypted = await Promise.all((data.messages || []).map(async (msg: Message) => { try { const key = msg.isSentByMe ? keys?.publicKey : msg.senderPublicKey || recipientPublicKey; if (key && msg.encryptedContent) return { ...msg, decryptedContent: await decryptMessage(msg.encryptedContent, key) }; } catch { /* ignore */ } return { ...msg, decryptedContent: '[Unable to decrypt]' }; })); setMessages(decrypted); } catch { /* ignore */ } }; + const markAsRead = async (conversationId: string) => { try { await fetch('/api/swarm/chat/messages', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationId }) }); setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, unreadCount: 0 } : c)); } catch { /* ignore */ } }; + const sendMessage = async (e: React.FormEvent) => { e.preventDefault(); if (!newMessage.trim() || !selectedConversation || !recipientPublicKey) return; setSending(true); try { const encrypted = await encryptMessage(newMessage, recipientPublicKey); const res = await fetch('/api/swarm/chat/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipientHandle: selectedConversation.participant2.handle, encryptedContent: encrypted, senderPublicKey: keys?.publicKey }) }); if (res.ok) { setMessages(prev => [...prev, { id: crypto.randomUUID(), senderHandle: user?.handle || '', encryptedContent: encrypted, decryptedContent: newMessage, isSentByMe: true, createdAt: new Date().toISOString() }]); setNewMessage(''); loadConversations(); } } catch { /* ignore */ } finally { setSending(false); } }; + const startNewChat = async (e: React.FormEvent) => { e.preventDefault(); if (!newChatHandle.trim()) return; setSending(true); try { const cleanHandle = newChatHandle.replace(/^@/, ''); const res = await fetch(`/api/users/\${encodeURIComponent(cleanHandle)}`); const data = await res.json(); if (!data.user?.chatPublicKey) { alert('This user has not enabled encrypted chat yet.'); return; } const encrypted = await encryptMessage('Hey!', data.user.chatPublicKey); const sendRes = await fetch('/api/swarm/chat/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipientHandle: cleanHandle, encryptedContent: encrypted, senderPublicKey: keys?.publicKey }) }); if (sendRes.ok) { setShowNewChat(false); setNewChatHandle(''); loadConversations(); } } catch { /* ignore */ } finally { setSending(false); } }; + const handlePasswordSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!password) return; setPasswordError(''); setIsProcessingPassword(true); try { if (needsPasswordToRestore) { const success = await restoreKeysWithPassword(password); if (!success) { setPasswordError('Incorrect password.'); return; } } else { await generateAndRegisterKeys(password); } setShowPasswordModal(false); setPassword(''); } catch { setPasswordError('Failed. Please try again.'); } finally { setIsProcessingPassword(false); } }; + const filteredConversations = conversations.filter(conv => conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) || conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase())); - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); - - const loadConversations = async () => { - try { - const res = await fetch('/api/swarm/chat/conversations'); - const data = await res.json(); - setConversations(data.conversations || []); - } catch (error) { - console.error('Failed to load conversations:', error); - } finally { - setLoading(false); - } - }; - - const loadMessages = async (conversationId: string) => { - try { - const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`); - const data = await res.json(); - setMessages(data.messages || []); - } catch (error) { - console.error('Failed to load messages:', error); - } - }; - - const markAsRead = async (conversationId: string) => { - try { - await fetch('/api/swarm/chat/messages', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ conversationId }), - }); - setConversations(prev => - prev.map(c => c.id === conversationId ? { ...c, unreadCount: 0 } : c) - ); - } catch (error) { - console.error('Failed to mark as read:', error); - } - }; - - const sendMessage = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newMessage.trim() || !selectedConversation) return; - - setSending(true); - try { - const res = await fetch('/api/swarm/chat/send', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - recipientHandle: selectedConversation.participant2.handle, - content: newMessage, - }), - }); - - if (res.ok) { - const data = await res.json(); - setMessages(prev => [...prev, data.message]); - setNewMessage(''); - loadConversations(); - } - } catch (error) { - console.error('Failed to send message:', error); - } finally { - setSending(false); - } - }; - - const startNewChat = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newChatHandle.trim()) return; - - setSending(true); - try { - const res = await fetch('/api/swarm/chat/send', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - recipientHandle: newChatHandle, - content: 'Hey! 👋', - }), - }); - - if (res.ok) { - setShowNewChat(false); - setNewChatHandle(''); - loadConversations(); - } - } catch (error) { - console.error('Failed to start chat:', error); - } finally { - setSending(false); - } - }; - - const filteredConversations = conversations.filter(conv => - conv.participant2.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || - conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - if (!user) { - return ( -
-
- -

Sign in to use Swarm Chat

-

End-to-end encrypted messaging across the Synapsis network

-
-
- ); - } - - return ( + if (!user) return

Sign in to use Swarm Chat

End-to-end encrypted messaging

; + if (!isReady) return

Loading encryption...

; + if (!hasKeys) return (
-
- {/* Sidebar */} -
-
-

Messages

- -
- -
- - setSearchQuery(e.target.value)} - /> -
- - {loading ? ( -
-
-

Loading conversations...

-
- ) : filteredConversations.length === 0 ? ( -
- -

No conversations yet

-

Start a chat with anyone on the swarm

- -
- ) : ( -
- {filteredConversations.map((conv) => ( - - ))} -
- )} -
- - {/* Main Chat Area */} -
- {selectedConversation ? ( - <> -
- -
- {selectedConversation.participant2.avatarUrl ? ( - - ) : ( - {selectedConversation.participant2.displayName.charAt(0).toUpperCase()} - )} -
-
-

{selectedConversation.participant2.displayName}

-

{formatFullHandle(selectedConversation.participant2.handle)}

-
-
- -
- {messages.map((msg) => ( -
- {!msg.isSentByMe && ( -
- {msg.senderAvatarUrl ? ( - - ) : ( - {(msg.senderDisplayName || msg.senderHandle).charAt(0).toUpperCase()} - )} -
- )} -
-
-
🔒 Encrypted
-
{msg.encryptedContent.substring(0, 40)}...
-
-
- {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - {msg.isSentByMe && ( - - {msg.readAt ? '✓✓' : msg.deliveredAt ? '✓' : '○'} - - )} -
-
-
- ))} -
-
- -
- setNewMessage(e.target.value)} - disabled={sending} - /> - -
- - ) : ( -
- -

Select a conversation

-

Choose a conversation from the sidebar to start chatting

-
- )} -
+
+ +

{needsPasswordToRestore ? 'Restore Your Chat Keys' : 'Enable End-to-End Encryption'}

+

{needsPasswordToRestore ? 'Enter your password to restore your encrypted chat keys.' : 'Generate encryption keys to start secure messaging.'}

+

Your private key is encrypted with your password.

+
- - {/* New Chat Modal */} - {showNewChat && ( -
setShowNewChat(false)}> + {showPasswordModal && ( +
setShowPasswordModal(false)}>
e.stopPropagation()}> -

Start New Chat

-

Enter the handle of the person you want to message

-
- setNewChatHandle(e.target.value)} - autoFocus - /> +

{needsPasswordToRestore ? 'Enter Your Password' : 'Secure Your Chat Keys'}

+

{needsPasswordToRestore ? 'Enter your account password to decrypt your chat keys.' : 'Enter your account password to encrypt your chat keys.'}

+ + setPassword(e.target.value)} autoFocus /> + {passwordError &&

{passwordError}

}
- - + +
@@ -340,4 +90,24 @@ export default function ChatPage() { )}
); + + return ( +
+
+
+

Messages

+
setSearchQuery(e.target.value)} />
+ {loading ?

Loading...

: filteredConversations.length === 0 ?

No conversations

:
{filteredConversations.map((conv) => )}
} +
+
+ {selectedConversation ? <> +
{selectedConversation.participant2.avatarUrl ? : {(selectedConversation.participant2.displayName || selectedConversation.participant2.handle).charAt(0).toUpperCase()}}

{selectedConversation.participant2.displayName}

{formatFullHandle(selectedConversation.participant2.handle)}

+
Messages are end-to-end encrypted.
{messages.map((msg) =>
{!msg.isSentByMe &&
{msg.senderAvatarUrl ? : {(msg.senderDisplayName || msg.senderHandle).charAt(0).toUpperCase()}}
}
{msg.decryptedContent || msg.encryptedContent}
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}{msg.isSentByMe && {msg.readAt ? '✓✓' : msg.deliveredAt ? '✓' : '○'}}
)}
+
setNewMessage(e.target.value)} disabled={sending || !recipientPublicKey} />
+ :

Select a conversation

} +
+
+ {showNewChat &&
setShowNewChat(false)}>
e.stopPropagation()}>

Start New Chat

setNewChatHandle(e.target.value)} autoFocus />
} +
+ ); } diff --git a/src/db/schema.ts b/src/db/schema.ts index 0f6bb91..d45d8cd 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -42,6 +42,9 @@ export const users = pgTable('users', { headerUrl: text('header_url'), privateKeyEncrypted: text('private_key_encrypted'), // For cryptographic signing publicKey: text('public_key').notNull(), + // E2E Chat keys (ECDH) - separate from signing keys + chatPublicKey: text('chat_public_key'), // ECDH public key for chat encryption + chatPrivateKeyEncrypted: text('chat_private_key_encrypted'), // Encrypted with user's password nodeId: uuid('node_id').references(() => nodes.id), // Bot-related fields isBot: boolean('is_bot').default(false).notNull(), @@ -908,6 +911,7 @@ export const chatConversationsRelations = relations(chatConversations, ({ one, m /** * Individual chat messages within conversations. * Messages are encrypted end-to-end using recipient's public key. + * Sender also gets a copy encrypted with their own public key. */ export const chatMessages = pgTable('chat_messages', { id: uuid('id').primaryKey().defaultRandom(), @@ -921,8 +925,12 @@ export const chatMessages = pgTable('chat_messages', { senderAvatarUrl: text('sender_avatar_url'), senderNodeDomain: text('sender_node_domain'), // null if local - // Message content (encrypted for recipient) + // Message content (encrypted for recipient with their public key) encryptedContent: text('encrypted_content').notNull(), + // Sender's copy (encrypted with sender's public key so they can read their own messages) + senderEncryptedContent: text('sender_encrypted_content'), + // Sender's ECDH public key (for E2E decryption by recipient) + senderChatPublicKey: text('sender_chat_public_key'), // Swarm sync info swarmMessageId: text('swarm_message_id').unique(), // Format: swarm:domain:uuid diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index 0052f68..cc205c3 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm'; import bcrypt from 'bcryptjs'; import { v4 as uuid } from 'uuid'; import { generateKeyPair } from '@/lib/crypto/keys'; +import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key'; import { cookies } from 'next/headers'; import { upsertHandleEntries } from '@/lib/federation/handles'; @@ -149,6 +150,9 @@ export async function registerUser( // Generate cryptographic keys const { publicKey, privateKey } = await generateKeyPair(); + // Encrypt the private key with user's password before storing + const encryptedPrivateKey = encryptPrivateKey(privateKey, password); + // Create the user const did = generateDID(); const passwordHash = await hashPassword(password); @@ -160,7 +164,7 @@ export async function registerUser( passwordHash, displayName: displayName || handle, publicKey, - privateKeyEncrypted: privateKey, // TODO: Encrypt with user's password + privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey), }).returning(); const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; @@ -195,5 +199,31 @@ export async function authenticateUser( throw new Error('Invalid email or password'); } + // Check if private key needs to be encrypted (migration from plaintext) + if (user.privateKeyEncrypted && !isEncryptedPrivateKeyStored(user.privateKeyEncrypted)) { + // Private key is stored in plaintext - encrypt it now + console.log(`[Auth] Encrypting private key for user ${user.handle}`); + const encryptedPrivateKey = encryptPrivateKey(user.privateKeyEncrypted, password); + await db.update(users) + .set({ privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey) }) + .where(eq(users.id, user.id)); + } + return user; } + +/** + * Check if stored private key is encrypted (vs plaintext PEM) + */ +function isEncryptedPrivateKeyStored(value: string): boolean { + if (!value) return false; + // Plaintext PEM keys start with -----BEGIN + if (value.startsWith('-----BEGIN')) return false; + // Try to parse as JSON + try { + const parsed = JSON.parse(value); + return parsed.encrypted && parsed.salt && parsed.iv; + } catch { + return false; + } +} diff --git a/src/lib/crypto/client-crypto.ts b/src/lib/crypto/client-crypto.ts new file mode 100644 index 0000000..c36a9e5 --- /dev/null +++ b/src/lib/crypto/client-crypto.ts @@ -0,0 +1,185 @@ +/** + * Client-Side E2E Encryption using Web Crypto API + * + * This runs in the browser. Private keys NEVER leave the client. + * Uses ECDH for key exchange and AES-GCM for encryption. + */ + +// Storage keys +const PRIVATE_KEY_STORAGE = 'synapsis_chat_private_key'; +const PUBLIC_KEY_STORAGE = 'synapsis_chat_public_key'; + +/** + * Generate a new ECDH key pair for chat + */ +export async function generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> { + const keyPair = await window.crypto.subtle.generateKey( + { + name: 'ECDH', + namedCurve: 'P-256', + }, + true, // extractable + ['deriveKey'] + ); + + const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey); + const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey); + + return { + publicKey: bufferToBase64(publicKeyBuffer), + privateKey: bufferToBase64(privateKeyBuffer), + }; +} + +/** + * Store keys in localStorage (encrypted with a passphrase in production) + */ +export function storeKeys(publicKey: string, privateKey: string): void { + localStorage.setItem(PUBLIC_KEY_STORAGE, publicKey); + localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey); +} + +/** + * Get stored keys + */ +export function getStoredKeys(): { publicKey: string | null; privateKey: string | null } { + return { + publicKey: localStorage.getItem(PUBLIC_KEY_STORAGE), + privateKey: localStorage.getItem(PRIVATE_KEY_STORAGE), + }; +} + +/** + * Check if chat keys exist + */ +export function hasChatKeys(): boolean { + const keys = getStoredKeys(); + return !!(keys.publicKey && keys.privateKey); +} + +/** + * Clear stored keys (logout) + */ +export function clearKeys(): void { + localStorage.removeItem(PUBLIC_KEY_STORAGE); + localStorage.removeItem(PRIVATE_KEY_STORAGE); +} + +/** + * Import a public key from base64 + */ +async function importPublicKey(publicKeyBase64: string): Promise { + const keyBuffer = base64ToBuffer(publicKeyBase64); + return window.crypto.subtle.importKey( + 'spki', + keyBuffer, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); +} + +/** + * Import a private key from base64 + */ +async function importPrivateKey(privateKeyBase64: string): Promise { + const keyBuffer = base64ToBuffer(privateKeyBase64); + return window.crypto.subtle.importKey( + 'pkcs8', + keyBuffer, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + ['deriveKey'] + ); +} + +/** + * Derive a shared AES key from ECDH + */ +async function deriveSharedKey( + myPrivateKey: CryptoKey, + theirPublicKey: CryptoKey +): Promise { + return window.crypto.subtle.deriveKey( + { name: 'ECDH', public: theirPublicKey }, + myPrivateKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypt a message for a recipient + */ +export async function encryptMessage( + message: string, + myPrivateKeyBase64: string, + theirPublicKeyBase64: string +): Promise { + const myPrivateKey = await importPrivateKey(myPrivateKeyBase64); + const theirPublicKey = await importPublicKey(theirPublicKeyBase64); + const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey); + + const encoder = new TextEncoder(); + const messageBytes = encoder.encode(message); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + const ciphertext = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + sharedKey, + messageBytes + ); + + // Combine iv + ciphertext + const combined = new Uint8Array(iv.length + ciphertext.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(ciphertext), iv.length); + + return bufferToBase64(combined.buffer); +} + +/** + * Decrypt a message from a sender + */ +export async function decryptMessage( + encryptedMessage: string, + myPrivateKeyBase64: string, + theirPublicKeyBase64: string +): Promise { + const myPrivateKey = await importPrivateKey(myPrivateKeyBase64); + const theirPublicKey = await importPublicKey(theirPublicKeyBase64); + const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey); + + const combined = base64ToBuffer(encryptedMessage); + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + + const decrypted = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + sharedKey, + ciphertext + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); +} + +// Utility functions +function bufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function base64ToBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} diff --git a/src/lib/crypto/e2e-chat.ts b/src/lib/crypto/e2e-chat.ts new file mode 100644 index 0000000..bae5c49 --- /dev/null +++ b/src/lib/crypto/e2e-chat.ts @@ -0,0 +1,104 @@ +/** + * End-to-End Encrypted Chat Cryptography + * + * Uses ECDH (Elliptic Curve Diffie-Hellman) for key exchange + * and AES-GCM for message encryption. + * + * This is a simplified version of the Signal Protocol approach. + * Private keys NEVER leave the client. + */ + +import * as crypto from 'crypto'; + +/** + * Generate an ECDH key pair for chat encryption + * The private key should be stored client-side only + */ +export function generateChatKeyPair(): { publicKey: string; privateKey: string } { + const ecdh = crypto.createECDH('prime256v1'); + ecdh.generateKeys(); + + return { + publicKey: ecdh.getPublicKey('base64'), + privateKey: ecdh.getPrivateKey('base64'), + }; +} + +/** + * Derive a shared secret from your private key and their public key + * This is the magic of ECDH - both parties derive the same secret + */ +export function deriveSharedSecret(myPrivateKey: string, theirPublicKey: string): Buffer { + const ecdh = crypto.createECDH('prime256v1'); + ecdh.setPrivateKey(Buffer.from(myPrivateKey, 'base64')); + + const sharedSecret = ecdh.computeSecret(Buffer.from(theirPublicKey, 'base64')); + + // Derive a proper AES key from the shared secret using HKDF + return crypto.createHash('sha256').update(sharedSecret).digest(); +} + +/** + * Encrypt a message using the shared secret + */ +export function encryptWithSharedSecret(message: string, sharedSecret: Buffer): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', sharedSecret, iv); + + const encrypted = Buffer.concat([ + cipher.update(message, 'utf8'), + cipher.final() + ]); + const authTag = cipher.getAuthTag(); + + // Combine: iv (12) + authTag (16) + ciphertext + const combined = Buffer.concat([iv, authTag, encrypted]); + return combined.toString('base64'); +} + +/** + * Decrypt a message using the shared secret + */ +export function decryptWithSharedSecret(encryptedMessage: string, sharedSecret: Buffer): string { + const combined = Buffer.from(encryptedMessage, 'base64'); + + const iv = combined.subarray(0, 12); + const authTag = combined.subarray(12, 28); + const ciphertext = combined.subarray(28); + + const decipher = crypto.createDecipheriv('aes-256-gcm', sharedSecret, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final() + ]); + + return decrypted.toString('utf8'); +} + +/** + * High-level: Encrypt a message for a recipient + * Uses sender's private key + recipient's public key + */ +export function encryptMessage( + message: string, + senderPrivateKey: string, + recipientPublicKey: string +): string { + const sharedSecret = deriveSharedSecret(senderPrivateKey, recipientPublicKey); + return encryptWithSharedSecret(message, sharedSecret); +} + +/** + * High-level: Decrypt a message from a sender + * Uses recipient's private key + sender's public key + */ +export function decryptMessage( + encryptedMessage: string, + recipientPrivateKey: string, + senderPublicKey: string +): string { + const sharedSecret = deriveSharedSecret(recipientPrivateKey, senderPublicKey); + return decryptWithSharedSecret(encryptedMessage, sharedSecret); +} diff --git a/src/lib/crypto/private-key.ts b/src/lib/crypto/private-key.ts new file mode 100644 index 0000000..185b8dc --- /dev/null +++ b/src/lib/crypto/private-key.ts @@ -0,0 +1,92 @@ +/** + * Private Key Encryption/Decryption + * + * Encrypts user private keys with their password using AES-256-GCM. + * This ensures server admins cannot read private keys without the user's password. + */ + +import * as crypto from 'crypto'; + +export interface EncryptedPrivateKey { + encrypted: string; // Base64 encoded ciphertext + auth tag + salt: string; // Base64 encoded salt for PBKDF2 + iv: string; // Base64 encoded initialization vector +} + +/** + * Encrypt a private key with the user's password + */ +export function encryptPrivateKey(privateKey: string, password: string): EncryptedPrivateKey { + const salt = crypto.randomBytes(32); + const iv = crypto.randomBytes(16); + + // Derive key from password using PBKDF2 + const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256'); + + // Encrypt with AES-256-GCM + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + let encrypted = cipher.update(privateKey, 'utf8', 'base64'); + encrypted += cipher.final('base64'); + const authTag = cipher.getAuthTag(); + + // Combine encrypted data with auth tag + const combined = Buffer.concat([Buffer.from(encrypted, 'base64'), authTag]).toString('base64'); + + return { + encrypted: combined, + salt: salt.toString('base64'), + iv: iv.toString('base64'), + }; +} + +/** + * Decrypt a private key with the user's password + */ +export function decryptPrivateKey(encryptedData: EncryptedPrivateKey, password: string): string { + const salt = Buffer.from(encryptedData.salt, 'base64'); + const iv = Buffer.from(encryptedData.iv, 'base64'); + const combined = Buffer.from(encryptedData.encrypted, 'base64'); + + // Derive key from password + const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256'); + + // Split encrypted data and auth tag (auth tag is last 16 bytes) + const authTag = combined.subarray(combined.length - 16); + const encryptedContent = combined.subarray(0, combined.length - 16); + + // Decrypt + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedContent); + decrypted = Buffer.concat([decrypted, decipher.final()]); + + return decrypted.toString('utf8'); +} + +/** + * Serialize encrypted private key for database storage + */ +export function serializeEncryptedKey(data: EncryptedPrivateKey): string { + return JSON.stringify(data); +} + +/** + * Deserialize encrypted private key from database + */ +export function deserializeEncryptedKey(serialized: string): EncryptedPrivateKey { + return JSON.parse(serialized); +} + +/** + * Check if a stored value is an encrypted private key (vs plaintext) + */ +export function isEncryptedPrivateKey(value: string): boolean { + if (!value) return false; + try { + const parsed = JSON.parse(value); + return parsed.encrypted && parsed.salt && parsed.iv; + } catch { + return false; + } +} diff --git a/src/lib/hooks/useChatEncryption.ts b/src/lib/hooks/useChatEncryption.ts new file mode 100644 index 0000000..5eb413f --- /dev/null +++ b/src/lib/hooks/useChatEncryption.ts @@ -0,0 +1,359 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +// Storage keys +const PRIVATE_KEY_STORAGE = 'synapsis_chat_private_key'; +const PUBLIC_KEY_STORAGE = 'synapsis_chat_public_key'; + +interface ChatKeys { + publicKey: string; + privateKey: string; +} + +interface ServerKeyData { + chatPublicKey: string | null; + chatPrivateKeyEncrypted: string | null; + hasKeys: boolean; +} + +/** + * Hook for managing E2E chat encryption + * Private keys are encrypted with user's password before server backup + */ +export function useChatEncryption() { + const [keys, setKeys] = useState(null); + const [isReady, setIsReady] = useState(false); + const [isRegistering, setIsRegistering] = useState(false); + const [needsPasswordToRestore, setNeedsPasswordToRestore] = useState(false); + const [serverKeyData, setServerKeyData] = useState(null); + + // Check for existing keys on mount + useEffect(() => { + checkKeys(); + }, []); + + const checkKeys = async () => { + // First check localStorage + const publicKey = localStorage.getItem(PUBLIC_KEY_STORAGE); + const privateKey = localStorage.getItem(PRIVATE_KEY_STORAGE); + + if (publicKey && privateKey) { + setKeys({ publicKey, privateKey }); + setIsReady(true); + return; + } + + // Check if server has encrypted backup + try { + const res = await fetch('/api/chat/keys'); + if (res.ok) { + const data: ServerKeyData = await res.json(); + setServerKeyData(data); + + if (data.hasKeys && data.chatPrivateKeyEncrypted) { + // Keys exist on server but not locally - need password to restore + setNeedsPasswordToRestore(true); + } + } + } catch (error) { + console.error('Failed to check server keys:', error); + } + + setIsReady(true); + }; + + // Restore keys from server backup using password + const restoreKeysWithPassword = useCallback(async (password: string): Promise => { + if (!serverKeyData?.chatPrivateKeyEncrypted || !serverKeyData?.chatPublicKey) { + throw new Error('No keys to restore'); + } + + try { + // Decrypt the private key using password + const privateKey = await decryptPrivateKeyWithPassword( + serverKeyData.chatPrivateKeyEncrypted, + password + ); + + // Store in localStorage + localStorage.setItem(PUBLIC_KEY_STORAGE, serverKeyData.chatPublicKey); + localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey); + + setKeys({ publicKey: serverKeyData.chatPublicKey, privateKey }); + setNeedsPasswordToRestore(false); + return true; + } catch (error) { + console.error('Failed to restore keys:', error); + return false; + } + }, [serverKeyData]); + + // Generate new keys and register with server (encrypted backup) + const generateAndRegisterKeys = useCallback(async (password: string) => { + setIsRegistering(true); + try { + // Generate ECDH key pair using Web Crypto API + const keyPair = await window.crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveKey'] + ); + + const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey); + const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey); + + const publicKey = bufferToBase64(publicKeyBuffer); + const privateKey = bufferToBase64(privateKeyBuffer); + + // Encrypt private key with password for server backup + const encryptedPrivateKey = await encryptPrivateKeyWithPassword(privateKey, password); + + // Store private key locally (NEVER sent unencrypted to server) + localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey); + localStorage.setItem(PUBLIC_KEY_STORAGE, publicKey); + + // Register public key + encrypted private key backup with server + const response = await fetch('/api/chat/keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatPublicKey: publicKey, + chatPrivateKeyEncrypted: encryptedPrivateKey, + }), + }); + + if (!response.ok) { + throw new Error('Failed to register chat keys'); + } + + setKeys({ publicKey, privateKey }); + setNeedsPasswordToRestore(false); + return { publicKey, privateKey }; + } finally { + setIsRegistering(false); + } + }, []); + + // Encrypt a message for a recipient + const encryptMessage = useCallback(async ( + message: string, + recipientPublicKey: string + ): Promise => { + if (!keys?.privateKey) { + throw new Error('No chat keys available'); + } + + const myPrivateKey = await importPrivateKey(keys.privateKey); + const theirPublicKey = await importPublicKey(recipientPublicKey); + const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey); + + const encoder = new TextEncoder(); + const messageBytes = encoder.encode(message); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + const ciphertext = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + sharedKey, + messageBytes + ); + + // Combine iv + ciphertext + const combined = new Uint8Array(iv.length + ciphertext.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(ciphertext), iv.length); + + return bufferToBase64(combined.buffer); + }, [keys]); + + // Decrypt a message from a sender + const decryptMessage = useCallback(async ( + encryptedMessage: string, + senderPublicKey: string + ): Promise => { + if (!keys?.privateKey) { + throw new Error('No chat keys available'); + } + + const myPrivateKey = await importPrivateKey(keys.privateKey); + const theirPublicKey = await importPublicKey(senderPublicKey); + const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey); + + const combined = base64ToBuffer(encryptedMessage); + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + + const decrypted = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + sharedKey, + ciphertext + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + }, [keys]); + + // Clear keys (on logout) + const clearKeys = useCallback(() => { + localStorage.removeItem(PRIVATE_KEY_STORAGE); + localStorage.removeItem(PUBLIC_KEY_STORAGE); + setKeys(null); + }, []); + + return { + keys, + isReady, + isRegistering, + hasKeys: !!keys, + needsPasswordToRestore, + generateAndRegisterKeys, + restoreKeysWithPassword, + encryptMessage, + decryptMessage, + clearKeys, + }; +} + +// ============================================ +// Password-based encryption for private key backup +// ============================================ + +async function encryptPrivateKeyWithPassword(privateKey: string, password: string): Promise { + const encoder = new TextEncoder(); + const salt = window.crypto.getRandomValues(new Uint8Array(16)); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + // Derive key from password using PBKDF2 + const passwordKey = await window.crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveKey'] + ); + + const aesKey = await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 100000, + hash: 'SHA-256', + }, + passwordKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'] + ); + + // Encrypt the private key + const ciphertext = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + aesKey, + encoder.encode(privateKey) + ); + + // Return as JSON with all components + return JSON.stringify({ + salt: bufferToBase64(salt.buffer), + iv: bufferToBase64(iv.buffer), + ciphertext: bufferToBase64(ciphertext), + }); +} + +async function decryptPrivateKeyWithPassword(encryptedData: string, password: string): Promise { + const { salt, iv, ciphertext } = JSON.parse(encryptedData); + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + // Derive key from password + const passwordKey = await window.crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveKey'] + ); + + const aesKey = await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: base64ToBuffer(salt), + iterations: 100000, + hash: 'SHA-256', + }, + passwordKey, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'] + ); + + // Decrypt + const decrypted = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBuffer(iv) }, + aesKey, + base64ToBuffer(ciphertext) + ); + + return decoder.decode(decrypted); +} + +// ============================================ +// ECDH Key helpers +// ============================================ + +async function importPublicKey(publicKeyBase64: string): Promise { + const keyBuffer = base64ToBuffer(publicKeyBase64); + return window.crypto.subtle.importKey( + 'spki', + keyBuffer, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); +} + +async function importPrivateKey(privateKeyBase64: string): Promise { + const keyBuffer = base64ToBuffer(privateKeyBase64); + return window.crypto.subtle.importKey( + 'pkcs8', + keyBuffer, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + ['deriveKey'] + ); +} + +async function deriveSharedKey( + myPrivateKey: CryptoKey, + theirPublicKey: CryptoKey +): Promise { + return window.crypto.subtle.deriveKey( + { name: 'ECDH', public: theirPublicKey }, + myPrivateKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +// ============================================ +// Buffer utilities +// ============================================ + +function bufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function base64ToBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +}