feat(chat): Implement client-side end-to-end encryption with key management
- Add chat keys API endpoint for storing and retrieving encrypted RSA public/private key pairs - Create client-side crypto utilities for E2E encryption/decryption with hybrid encryption support - Implement useChatEncryption hook for managing encryption state and key generation in chat UI - Add chatPublicKey and chatPrivateKeyEncrypted fields to user schema for key storage - Update chat messages API to return encrypted content with sender's public key for decryption - Modify send message endpoint to accept pre-encrypted content from client while maintaining legacy server-side encryption - Update chat page UI to integrate client-side encryption workflow - Ensure private keys are encrypted client-side with user password before transmission to server - Server cannot decrypt message content in E2E mode, providing true end-to-end encryption
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,10 +71,23 @@ export async function GET(request: NextRequest) {
|
|||||||
const messagesWithDecryption = messages.map((msg) => {
|
const messagesWithDecryption = messages.map((msg) => {
|
||||||
const isSentByMe = msg.senderHandle === session.user.handle;
|
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 {
|
return {
|
||||||
...msg,
|
id: msg.id,
|
||||||
// Only include encrypted content - client will decrypt
|
senderHandle: msg.senderHandle,
|
||||||
content: undefined, // Remove plaintext
|
senderDisplayName: msg.senderDisplayName,
|
||||||
|
senderAvatarUrl: msg.senderAvatarUrl,
|
||||||
|
senderPublicKey: msg.senderChatPublicKey, // For E2E decryption
|
||||||
|
encryptedContent: encryptedForViewer,
|
||||||
|
deliveredAt: msg.deliveredAt,
|
||||||
|
readAt: msg.readAt,
|
||||||
|
createdAt: msg.createdAt,
|
||||||
isSentByMe,
|
isSentByMe,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types';
|
|||||||
|
|
||||||
const sendMessageSchema = z.object({
|
const sendMessageSchema = z.object({
|
||||||
recipientHandle: z.string(),
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -152,14 +158,29 @@ export async function POST(request: NextRequest) {
|
|||||||
recipientPublicKey = recipientUser.publicKey;
|
recipientPublicKey = recipientUser.publicKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt the message with recipient's public key
|
// Handle encryption - either client-side (E2E) or server-side (legacy)
|
||||||
console.log('[Chat Send] Encrypting message...');
|
|
||||||
let encryptedContent: string;
|
let encryptedContent: string;
|
||||||
try {
|
let senderEncryptedContent: string | null = null;
|
||||||
encryptedContent = encryptMessage(data.content, recipientPublicKey);
|
|
||||||
} catch (encError) {
|
if (data.encryptedContent) {
|
||||||
console.error('[Chat Send] Encryption failed:', encError);
|
// E2E mode: client already encrypted the message
|
||||||
return NextResponse.json({ error: 'Encryption failed' }, { status: 500 });
|
// 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
|
// Get or create conversation
|
||||||
@@ -176,7 +197,7 @@ export async function POST(request: NextRequest) {
|
|||||||
participant1Id: sender.id,
|
participant1Id: sender.id,
|
||||||
participant2Handle: recipientHandle,
|
participant2Handle: recipientHandle,
|
||||||
lastMessageAt: new Date(),
|
lastMessageAt: new Date(),
|
||||||
lastMessagePreview: data.content.substring(0, 100),
|
lastMessagePreview: '🔒 Encrypted message',
|
||||||
}).returning();
|
}).returning();
|
||||||
conversation = newConversation;
|
conversation = newConversation;
|
||||||
}
|
}
|
||||||
@@ -194,6 +215,8 @@ export async function POST(request: NextRequest) {
|
|||||||
senderAvatarUrl: sender.avatarUrl,
|
senderAvatarUrl: sender.avatarUrl,
|
||||||
senderNodeDomain: null, // Local sender
|
senderNodeDomain: null, // Local sender
|
||||||
encryptedContent,
|
encryptedContent,
|
||||||
|
senderEncryptedContent,
|
||||||
|
senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey, // For E2E decryption
|
||||||
swarmMessageId,
|
swarmMessageId,
|
||||||
deliveredAt: isRemote ? null : new Date(), // Delivered immediately if local
|
deliveredAt: isRemote ? null : new Date(), // Delivered immediately if local
|
||||||
readAt: null,
|
readAt: null,
|
||||||
@@ -203,7 +226,7 @@ export async function POST(request: NextRequest) {
|
|||||||
await db.update(chatConversations)
|
await db.update(chatConversations)
|
||||||
.set({
|
.set({
|
||||||
lastMessageAt: new Date(),
|
lastMessageAt: new Date(),
|
||||||
lastMessagePreview: data.content.substring(0, 100),
|
lastMessagePreview: '🔒 Encrypted message',
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(chatConversations.id, conversation.id));
|
.where(eq(chatConversations.id, conversation.id));
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
website: user.website,
|
website: user.website,
|
||||||
movedTo: user.movedTo,
|
movedTo: user.movedTo,
|
||||||
isBot: user.isBot,
|
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
|
// If this is a bot, include owner info
|
||||||
|
|||||||
+60
-290
@@ -2,17 +2,14 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
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 { formatFullHandle } from '@/lib/utils/handle';
|
||||||
import './chat.css';
|
import './chat.css';
|
||||||
|
|
||||||
interface Conversation {
|
interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
participant2: {
|
participant2: { handle: string; displayName: string; avatarUrl: string | null; chatPublicKey: string | null; };
|
||||||
handle: string;
|
|
||||||
displayName: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
};
|
|
||||||
lastMessageAt: string;
|
lastMessageAt: string;
|
||||||
lastMessagePreview: string;
|
lastMessagePreview: string;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
@@ -23,7 +20,9 @@ interface Message {
|
|||||||
senderHandle: string;
|
senderHandle: string;
|
||||||
senderDisplayName?: string;
|
senderDisplayName?: string;
|
||||||
senderAvatarUrl?: string;
|
senderAvatarUrl?: string;
|
||||||
|
senderPublicKey?: string;
|
||||||
encryptedContent: string;
|
encryptedContent: string;
|
||||||
|
decryptedContent?: string;
|
||||||
isSentByMe: boolean;
|
isSentByMe: boolean;
|
||||||
deliveredAt?: string;
|
deliveredAt?: string;
|
||||||
readAt?: string;
|
readAt?: string;
|
||||||
@@ -32,6 +31,7 @@ interface Message {
|
|||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { keys, isReady, hasKeys, isRegistering, needsPasswordToRestore, generateAndRegisterKeys, restoreKeysWithPassword, encryptMessage, decryptMessage } = useChatEncryption();
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
|
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
@@ -41,298 +41,48 @@ export default function ChatPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [recipientPublicKey, setRecipientPublicKey] = useState<string | null>(null);
|
||||||
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [passwordError, setPasswordError] = useState('');
|
||||||
|
const [isProcessingPassword, setIsProcessingPassword] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { if (user && hasKeys) loadConversations(); }, [user, hasKeys]);
|
||||||
if (user) {
|
useEffect(() => { if (selectedConversation && hasKeys) { loadMessages(selectedConversation.id); markAsRead(selectedConversation.id); fetchRecipientKey(selectedConversation.participant2.handle); } }, [selectedConversation, hasKeys]);
|
||||||
loadConversations();
|
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
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); } };
|
||||||
if (selectedConversation) {
|
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); } };
|
||||||
loadMessages(selectedConversation.id);
|
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 */ } };
|
||||||
markAsRead(selectedConversation.id);
|
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); } };
|
||||||
}, [selectedConversation]);
|
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(() => {
|
if (!user) return <div className="chat-page"><div className="chat-empty-state"><MessageCircle size={64} /><h2>Sign in to use Swarm Chat</h2><p>End-to-end encrypted messaging</p></div></div>;
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
if (!isReady) return <div className="chat-page"><div className="chat-empty-state"><div className="spinner" /><p>Loading encryption...</p></div></div>;
|
||||||
}, [messages]);
|
if (!hasKeys) return (
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="chat-page">
|
|
||||||
<div className="chat-empty-state">
|
|
||||||
<MessageCircle size={64} />
|
|
||||||
<h2>Sign in to use Swarm Chat</h2>
|
|
||||||
<p>End-to-end encrypted messaging across the Synapsis network</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="chat-page">
|
<div className="chat-page">
|
||||||
<div className="chat-container">
|
<div className="chat-empty-state">
|
||||||
{/* Sidebar */}
|
<Shield size={64} />
|
||||||
<div className={`chat-sidebar ${selectedConversation ? 'mobile-hidden' : ''}`}>
|
<h2>{needsPasswordToRestore ? 'Restore Your Chat Keys' : 'Enable End-to-End Encryption'}</h2>
|
||||||
<div className="chat-sidebar-header">
|
<p>{needsPasswordToRestore ? 'Enter your password to restore your encrypted chat keys.' : 'Generate encryption keys to start secure messaging.'}</p>
|
||||||
<h1>Messages</h1>
|
<p className="text-sm text-gray-500 mt-2"><Lock size={14} className="inline mr-1" />Your private key is encrypted with your password.</p>
|
||||||
<button className="btn-icon" onClick={() => setShowNewChat(true)} title="New chat">
|
<button className="btn-primary mt-4" onClick={() => setShowPasswordModal(true)} disabled={isRegistering}><Key size={18} className="mr-2" />{needsPasswordToRestore ? 'Restore Keys' : 'Enable Encrypted Chat'}</button>
|
||||||
<Plus size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="chat-search">
|
|
||||||
<Search size={18} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search conversations..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="chat-loading">
|
|
||||||
<div className="spinner" />
|
|
||||||
<p>Loading conversations...</p>
|
|
||||||
</div>
|
|
||||||
) : filteredConversations.length === 0 ? (
|
|
||||||
<div className="chat-empty-state">
|
|
||||||
<MessageCircle size={48} />
|
|
||||||
<h3>No conversations yet</h3>
|
|
||||||
<p>Start a chat with anyone on the swarm</p>
|
|
||||||
<button className="btn-primary" onClick={() => setShowNewChat(true)}>
|
|
||||||
<Plus size={18} />
|
|
||||||
New Chat
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="conversations-list">
|
|
||||||
{filteredConversations.map((conv) => (
|
|
||||||
<button
|
|
||||||
key={conv.id}
|
|
||||||
className={`conversation-item ${selectedConversation?.id === conv.id ? 'active' : ''}`}
|
|
||||||
onClick={() => setSelectedConversation(conv)}
|
|
||||||
>
|
|
||||||
<div className="conversation-avatar">
|
|
||||||
{conv.participant2.avatarUrl ? (
|
|
||||||
<img src={conv.participant2.avatarUrl} alt={conv.participant2.displayName} />
|
|
||||||
) : (
|
|
||||||
<span>{conv.participant2.displayName.charAt(0).toUpperCase()}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="conversation-content">
|
|
||||||
<div className="conversation-header">
|
|
||||||
<span className="conversation-name">{conv.participant2.displayName}</span>
|
|
||||||
{conv.unreadCount > 0 && (
|
|
||||||
<span className="unread-badge">{conv.unreadCount}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="conversation-handle">{formatFullHandle(conv.participant2.handle)}</div>
|
|
||||||
<div className="conversation-preview">{conv.lastMessagePreview}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Chat Area */}
|
|
||||||
<div className={`chat-main ${!selectedConversation ? 'mobile-hidden' : ''}`}>
|
|
||||||
{selectedConversation ? (
|
|
||||||
<>
|
|
||||||
<div className="chat-header">
|
|
||||||
<button className="btn-icon back-button" onClick={() => setSelectedConversation(null)}>
|
|
||||||
<ArrowLeft size={20} />
|
|
||||||
</button>
|
|
||||||
<div className="chat-header-avatar">
|
|
||||||
{selectedConversation.participant2.avatarUrl ? (
|
|
||||||
<img src={selectedConversation.participant2.avatarUrl} alt="" />
|
|
||||||
) : (
|
|
||||||
<span>{selectedConversation.participant2.displayName.charAt(0).toUpperCase()}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="chat-header-info">
|
|
||||||
<h2>{selectedConversation.participant2.displayName}</h2>
|
|
||||||
<p>{formatFullHandle(selectedConversation.participant2.handle)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="chat-messages">
|
|
||||||
{messages.map((msg) => (
|
|
||||||
<div key={msg.id} className={`message ${msg.isSentByMe ? 'sent' : 'received'}`}>
|
|
||||||
{!msg.isSentByMe && (
|
|
||||||
<div className="message-avatar">
|
|
||||||
{msg.senderAvatarUrl ? (
|
|
||||||
<img src={msg.senderAvatarUrl} alt="" />
|
|
||||||
) : (
|
|
||||||
<span>{(msg.senderDisplayName || msg.senderHandle).charAt(0).toUpperCase()}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="message-content">
|
|
||||||
<div className="message-bubble">
|
|
||||||
<div className="encrypted-indicator">🔒 Encrypted</div>
|
|
||||||
<div className="encrypted-preview">{msg.encryptedContent.substring(0, 40)}...</div>
|
|
||||||
</div>
|
|
||||||
<div className="message-meta">
|
|
||||||
<span>{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
|
||||||
{msg.isSentByMe && (
|
|
||||||
<span className="delivery-status">
|
|
||||||
{msg.readAt ? '✓✓' : msg.deliveredAt ? '✓' : '○'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="chat-input" onSubmit={sendMessage}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Type a message..."
|
|
||||||
value={newMessage}
|
|
||||||
onChange={(e) => setNewMessage(e.target.value)}
|
|
||||||
disabled={sending}
|
|
||||||
/>
|
|
||||||
<button type="submit" className="btn-icon send-button" disabled={sending || !newMessage.trim()}>
|
|
||||||
<Send size={20} />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="chat-empty-state">
|
|
||||||
<MessageCircle size={64} />
|
|
||||||
<h2>Select a conversation</h2>
|
|
||||||
<p>Choose a conversation from the sidebar to start chatting</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{showPasswordModal && (
|
||||||
{/* New Chat Modal */}
|
<div className="modal-overlay" onClick={() => setShowPasswordModal(false)}>
|
||||||
{showNewChat && (
|
|
||||||
<div className="modal-overlay" onClick={() => setShowNewChat(false)}>
|
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2>Start New Chat</h2>
|
<h2>{needsPasswordToRestore ? 'Enter Your Password' : 'Secure Your Chat Keys'}</h2>
|
||||||
<p>Enter the handle of the person you want to message</p>
|
<p>{needsPasswordToRestore ? 'Enter your account password to decrypt your chat keys.' : 'Enter your account password to encrypt your chat keys.'}</p>
|
||||||
<form onSubmit={startNewChat}>
|
<form onSubmit={handlePasswordSubmit}>
|
||||||
<input
|
<input type="password" placeholder="Your account password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus />
|
||||||
type="text"
|
{passwordError && <p className="text-red-500 text-sm mt-2">{passwordError}</p>}
|
||||||
placeholder="user@node.domain or localuser"
|
|
||||||
value={newChatHandle}
|
|
||||||
onChange={(e) => setNewChatHandle(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button type="button" className="btn-secondary" onClick={() => setShowNewChat(false)}>
|
<button type="button" className="btn-secondary" onClick={() => { setShowPasswordModal(false); setPassword(''); setPasswordError(''); }}>Cancel</button>
|
||||||
Cancel
|
<button type="submit" className="btn-primary" disabled={isProcessingPassword || !password}>{isProcessingPassword ? 'Processing...' : (needsPasswordToRestore ? 'Restore' : 'Enable')}</button>
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn-primary" disabled={sending || !newChatHandle.trim()}>
|
|
||||||
Start Chat
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,4 +90,24 @@ export default function ChatPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-page">
|
||||||
|
<div className="chat-container">
|
||||||
|
<div className={`chat-sidebar \${selectedConversation ? 'mobile-hidden' : ''}`}>
|
||||||
|
<div className="chat-sidebar-header"><h1><Lock size={18} className="inline mr-2" />Messages</h1><button className="btn-icon" onClick={() => setShowNewChat(true)} title="New chat"><Plus size={20} /></button></div>
|
||||||
|
<div className="chat-search"><Search size={18} /><input type="text" placeholder="Search conversations..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /></div>
|
||||||
|
{loading ? <div className="chat-loading"><div className="spinner" /><p>Loading...</p></div> : filteredConversations.length === 0 ? <div className="chat-empty-state"><MessageCircle size={48} /><h3>No conversations</h3><button className="btn-primary" onClick={() => setShowNewChat(true)}><Plus size={18} />New Chat</button></div> : <div className="conversations-list">{filteredConversations.map((conv) => <button key={conv.id} className={`conversation-item \${selectedConversation?.id === conv.id ? 'active' : ''}`} onClick={() => setSelectedConversation(conv)}><div className="conversation-avatar">{conv.participant2.avatarUrl ? <img src={conv.participant2.avatarUrl} alt="" /> : <span>{(conv.participant2.displayName || conv.participant2.handle).charAt(0).toUpperCase()}</span>}</div><div className="conversation-content"><div className="conversation-header"><span className="conversation-name">{conv.participant2.displayName}</span>{conv.unreadCount > 0 && <span className="unread-badge">{conv.unreadCount}</span>}</div><div className="conversation-handle">{formatFullHandle(conv.participant2.handle)}</div><div className="conversation-preview"><Lock size={12} className="inline mr-1" />{conv.lastMessagePreview}</div></div></button>)}</div>}
|
||||||
|
</div>
|
||||||
|
<div className={`chat-main \${!selectedConversation ? 'mobile-hidden' : ''}`}>
|
||||||
|
{selectedConversation ? <>
|
||||||
|
<div className="chat-header"><button className="btn-icon back-button" onClick={() => setSelectedConversation(null)}><ArrowLeft size={20} /></button><div className="chat-header-avatar">{selectedConversation.participant2.avatarUrl ? <img src={selectedConversation.participant2.avatarUrl} alt="" /> : <span>{(selectedConversation.participant2.displayName || selectedConversation.participant2.handle).charAt(0).toUpperCase()}</span>}</div><div className="chat-header-info"><h2>{selectedConversation.participant2.displayName}</h2><p><Lock size={12} className="inline mr-1" />{formatFullHandle(selectedConversation.participant2.handle)}</p></div></div>
|
||||||
|
<div className="chat-messages"><div className="encryption-notice"><Shield size={16} /><span>Messages are end-to-end encrypted.</span></div>{messages.map((msg) => <div key={msg.id} className={`message \${msg.isSentByMe ? 'sent' : 'received'}`}>{!msg.isSentByMe && <div className="message-avatar">{msg.senderAvatarUrl ? <img src={msg.senderAvatarUrl} alt="" /> : <span>{(msg.senderDisplayName || msg.senderHandle).charAt(0).toUpperCase()}</span>}</div>}<div className="message-content"><div className="message-bubble">{msg.decryptedContent || msg.encryptedContent}</div><div className="message-meta"><span>{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>{msg.isSentByMe && <span className="delivery-status">{msg.readAt ? '✓✓' : msg.deliveredAt ? '✓' : '○'}</span>}</div></div></div>)}<div ref={messagesEndRef} /></div>
|
||||||
|
<form className="chat-input" onSubmit={sendMessage}><input type="text" placeholder={recipientPublicKey ? "Type a message..." : "Recipient hasn't enabled encryption..."} value={newMessage} onChange={(e) => setNewMessage(e.target.value)} disabled={sending || !recipientPublicKey} /><button type="submit" className="btn-icon send-button" disabled={sending || !newMessage.trim() || !recipientPublicKey}><Send size={20} /></button></form>
|
||||||
|
</> : <div className="chat-empty-state"><MessageCircle size={64} /><h2>Select a conversation</h2></div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showNewChat && <div className="modal-overlay" onClick={() => setShowNewChat(false)}><div className="modal-content" onClick={(e) => e.stopPropagation()}><h2>Start New Chat</h2><form onSubmit={startNewChat}><input type="text" placeholder="user@node.domain" value={newChatHandle} onChange={(e) => setNewChatHandle(e.target.value)} autoFocus /><div className="modal-actions"><button type="button" className="btn-secondary" onClick={() => setShowNewChat(false)}>Cancel</button><button type="submit" className="btn-primary" disabled={sending || !newChatHandle.trim()}>Start Chat</button></div></form></div></div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -42,6 +42,9 @@ export const users = pgTable('users', {
|
|||||||
headerUrl: text('header_url'),
|
headerUrl: text('header_url'),
|
||||||
privateKeyEncrypted: text('private_key_encrypted'), // For cryptographic signing
|
privateKeyEncrypted: text('private_key_encrypted'), // For cryptographic signing
|
||||||
publicKey: text('public_key').notNull(),
|
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),
|
nodeId: uuid('node_id').references(() => nodes.id),
|
||||||
// Bot-related fields
|
// Bot-related fields
|
||||||
isBot: boolean('is_bot').default(false).notNull(),
|
isBot: boolean('is_bot').default(false).notNull(),
|
||||||
@@ -908,6 +911,7 @@ export const chatConversationsRelations = relations(chatConversations, ({ one, m
|
|||||||
/**
|
/**
|
||||||
* Individual chat messages within conversations.
|
* Individual chat messages within conversations.
|
||||||
* Messages are encrypted end-to-end using recipient's public key.
|
* 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', {
|
export const chatMessages = pgTable('chat_messages', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
@@ -921,8 +925,12 @@ export const chatMessages = pgTable('chat_messages', {
|
|||||||
senderAvatarUrl: text('sender_avatar_url'),
|
senderAvatarUrl: text('sender_avatar_url'),
|
||||||
senderNodeDomain: text('sender_node_domain'), // null if local
|
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(),
|
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
|
// Swarm sync info
|
||||||
swarmMessageId: text('swarm_message_id').unique(), // Format: swarm:domain:uuid
|
swarmMessageId: text('swarm_message_id').unique(), // Format: swarm:domain:uuid
|
||||||
|
|||||||
+31
-1
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { generateKeyPair } from '@/lib/crypto/keys';
|
import { generateKeyPair } from '@/lib/crypto/keys';
|
||||||
|
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||||
|
|
||||||
@@ -149,6 +150,9 @@ export async function registerUser(
|
|||||||
// Generate cryptographic keys
|
// Generate cryptographic keys
|
||||||
const { publicKey, privateKey } = await generateKeyPair();
|
const { publicKey, privateKey } = await generateKeyPair();
|
||||||
|
|
||||||
|
// Encrypt the private key with user's password before storing
|
||||||
|
const encryptedPrivateKey = encryptPrivateKey(privateKey, password);
|
||||||
|
|
||||||
// Create the user
|
// Create the user
|
||||||
const did = generateDID();
|
const did = generateDID();
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
@@ -160,7 +164,7 @@ export async function registerUser(
|
|||||||
passwordHash,
|
passwordHash,
|
||||||
displayName: displayName || handle,
|
displayName: displayName || handle,
|
||||||
publicKey,
|
publicKey,
|
||||||
privateKeyEncrypted: privateKey, // TODO: Encrypt with user's password
|
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
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');
|
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;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<CryptoKey> {
|
||||||
|
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<CryptoKey> {
|
||||||
|
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<CryptoKey> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ChatKeys | null>(null);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [isRegistering, setIsRegistering] = useState(false);
|
||||||
|
const [needsPasswordToRestore, setNeedsPasswordToRestore] = useState(false);
|
||||||
|
const [serverKeyData, setServerKeyData] = useState<ServerKeyData | null>(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<boolean> => {
|
||||||
|
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<string> => {
|
||||||
|
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<string> => {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<CryptoKey> {
|
||||||
|
const keyBuffer = base64ToBuffer(publicKeyBase64);
|
||||||
|
return window.crypto.subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
keyBuffer,
|
||||||
|
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||||
|
false,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPrivateKey(privateKeyBase64: string): Promise<CryptoKey> {
|
||||||
|
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<CryptoKey> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user