diff --git a/check-keys.ts b/check-keys.ts new file mode 100644 index 0000000..2547e89 --- /dev/null +++ b/check-keys.ts @@ -0,0 +1,19 @@ +import { db, users } from './src/db'; +import { eq } from 'drizzle-orm'; + +async function checkKeys() { + const allUsers = await db.select({ + handle: users.handle, + hasChatPublicKey: users.chatPublicKey, + hasChatPrivateKeyEncrypted: users.chatPrivateKeyEncrypted, + }).from(users); + + console.log('Users and their chat keys:'); + allUsers.forEach(u => { + console.log(`- ${u.handle}: chatPublicKey=${!!u.hasChatPublicKey}, chatPrivateKeyEncrypted=${!!u.hasChatPrivateKeyEncrypted}`); + }); + + process.exit(0); +} + +checkKeys().catch(console.error); diff --git a/clean-old-messages.ts b/clean-old-messages.ts new file mode 100644 index 0000000..157939b --- /dev/null +++ b/clean-old-messages.ts @@ -0,0 +1,14 @@ +import { db, chatMessages } from './src/db'; +import { isNull } from 'drizzle-orm'; + +async function cleanOldMessages() { + console.log('Deleting old RSA-encrypted messages...'); + + const result = await db.delete(chatMessages) + .where(isNull(chatMessages.senderChatPublicKey)); + + console.log('Deleted old messages. Now only E2E encrypted messages remain.'); + process.exit(0); +} + +cleanOldMessages().catch(console.error); diff --git a/inspect_messages.ts b/inspect_messages.ts new file mode 100644 index 0000000..0c20387 --- /dev/null +++ b/inspect_messages.ts @@ -0,0 +1,26 @@ +import 'dotenv/config'; +import { db } from './src/db/index'; +import { chatMessages } from './src/db/schema'; +import { desc } from 'drizzle-orm'; + +async function main() { + console.log('--- LATEST CHAT MESSAGES ---'); + try { + const messages = await db.select().from(chatMessages).orderBy(desc(chatMessages.createdAt)).limit(20); + console.log(`Found ${messages.length} messages.`); + messages.forEach(m => { + console.log(`\nID: ${m.id}`); + console.log(`Sender: ${m.senderHandle}`); + console.log(`Created: ${m.createdAt}`); + console.log(`EncryptedContent (${m.encryptedContent?.length} chars): ${m.encryptedContent}`); + console.log(`SenderEncryptedContent (${m.senderEncryptedContent?.length} chars): ${m.senderEncryptedContent}`); + console.log('-----------------------------------'); + }); + + } catch (e) { + console.error('Error:', e); + } + process.exit(0); +} + +main(); diff --git a/src/app/api/swarm/chat/send/route.ts b/src/app/api/swarm/chat/send/route.ts index 7ed0bf3..9e4a4a6 100644 --- a/src/app/api/swarm/chat/send/route.ts +++ b/src/app/api/swarm/chat/send/route.ts @@ -52,7 +52,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid input', details: parseResult.error.issues }, { status: 400 }); } const data = parseResult.data; - console.log('[Chat Send] Input validated. Recipient:', data.recipientHandle); + console.log('[Chat Send] Input validated. Recipient:', data.recipientHandle, 'Has senderPublicKey:', !!data.senderPublicKey); // Get sender info const sender = await db.query.users.findFirst({ @@ -207,7 +207,7 @@ export async function POST(request: NextRequest) { const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; const swarmMessageId = `swarm:${nodeDomain}:${messageId}`; - console.log('[Chat Send] Inserting message into DB'); + console.log('[Chat Send] Inserting message into DB, senderPublicKey from client:', !!data.senderPublicKey, 'from DB:', !!sender.chatPublicKey); const [newMessage] = await db.insert(chatMessages).values({ conversationId: conversation.id, senderHandle: sender.handle, @@ -216,7 +216,8 @@ export async function POST(request: NextRequest) { senderNodeDomain: null, // Local sender encryptedContent, senderEncryptedContent, - senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey, // For E2E decryption + // Use client-provided key first, fall back to database + senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey, swarmMessageId, deliveredAt: isRemote ? null : new Date(), // Delivered immediately if local readAt: null, diff --git a/src/app/chat/chat.css b/src/app/chat/chat.css deleted file mode 100644 index 438d094..0000000 --- a/src/app/chat/chat.css +++ /dev/null @@ -1,576 +0,0 @@ -/* Chat Page Styles */ - -.chat-page { - height: 100vh; - /* Use dynamic viewport height for mobile browsers */ - height: 100dvh; - max-height: 100dvh; - display: flex; - flex-direction: column; -} - -.chat-container { - display: grid; - grid-template-columns: 400px 1fr; - height: 100%; - border-left: 1px solid var(--border); - border-right: 1px solid var(--border); - overflow: hidden; - background: var(--background); -} - -/* Sidebar */ -.chat-sidebar { - display: flex; - flex-direction: column; - border-right: 1px solid var(--border); - background: var(--background); -} - -.chat-sidebar-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 24px; - border-bottom: 1px solid var(--border); -} - -.chat-sidebar-header h1 { - font-size: 20px; - font-weight: 600; - margin: 0; -} - -.chat-search { - display: flex; - align-items: center; - gap: 12px; - padding: 16px 24px; - border-bottom: 1px solid var(--border); - background: var(--background-secondary); -} - -.chat-search svg { - color: var(--foreground-tertiary); - flex-shrink: 0; -} - -.chat-search input { - flex: 1; - background: transparent; - border: none; - outline: none; - color: var(--foreground); - font-size: 14px; -} - -.chat-search input::placeholder { - color: var(--foreground-tertiary); -} - -/* Conversations List */ -.conversations-list { - flex: 1; - overflow-y: auto; -} - -.conversation-item { - display: flex; - gap: 16px; - padding: 16px 24px; - border: none; - border-bottom: 1px solid var(--border); - background: transparent; - width: 100%; - text-align: left; - cursor: pointer; - transition: background 0.15s ease; -} - -.conversation-item:hover { - background: var(--background-secondary); -} - -.conversation-item.active { - background: var(--background-tertiary); - border-left: 3px solid var(--accent); -} - -.conversation-avatar { - width: 48px; - height: 48px; - border-radius: var(--radius-full); - background: var(--background-tertiary); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - overflow: hidden; - border: 1px solid var(--border); -} - -.conversation-avatar img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.conversation-avatar span { - font-size: 18px; - font-weight: 600; - color: var(--foreground); -} - -.conversation-content { - flex: 1; - min-width: 0; -} - -.conversation-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 4px; -} - -.conversation-name { - font-weight: 600; - font-size: 15px; - color: var(--foreground); -} - -.unread-badge { - background: var(--accent); - color: var(--background); - font-size: 11px; - font-weight: 600; - padding: 2px 8px; - border-radius: var(--radius-full); - min-width: 20px; - text-align: center; -} - -.conversation-handle { - font-size: 13px; - color: var(--foreground-secondary); - margin-bottom: 4px; -} - -.conversation-preview { - font-size: 14px; - color: var(--foreground-tertiary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Main Chat Area */ -.chat-main { - display: flex; - flex-direction: column; - background: var(--background); -} - -.chat-header { - display: flex; - align-items: center; - gap: 16px; - padding: 20px 24px; - border-bottom: 1px solid var(--border); - background: var(--background-secondary); -} - -.back-button { - display: none; -} - -.chat-header-avatar { - width: 40px; - height: 40px; - border-radius: var(--radius-full); - background: var(--background-tertiary); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - border: 1px solid var(--border); - flex-shrink: 0; -} - -.chat-header-avatar img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.chat-header-avatar span { - font-size: 16px; - font-weight: 600; - color: var(--foreground); -} - -.chat-header-info { - flex: 1; - min-width: 0; -} - -.chat-header-info h2 { - font-size: 16px; - font-weight: 600; - margin: 0 0 2px 0; - color: var(--foreground); -} - -.chat-header-info p { - font-size: 13px; - color: var(--foreground-secondary); - margin: 0; -} - -/* Messages */ -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 24px; - display: flex; - flex-direction: column; - gap: 16px; -} - -.message { - display: flex; - gap: 12px; - max-width: 70%; -} - -.message.sent { - flex-direction: row-reverse; - margin-left: auto; -} - -.message-avatar { - width: 32px; - height: 32px; - border-radius: var(--radius-full); - background: var(--background-tertiary); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - border: 1px solid var(--border); - flex-shrink: 0; -} - -.message-avatar img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.message-avatar span { - font-size: 14px; - font-weight: 600; - color: var(--foreground); -} - -.message-content { - display: flex; - flex-direction: column; - gap: 4px; -} - -.message.sent .message-content { - align-items: flex-end; -} - -.message-bubble { - padding: 12px 16px; - border-radius: var(--radius-lg); - background: var(--background-secondary); - border: 1px solid var(--border); - word-wrap: break-word; -} - -.message.sent .message-bubble { - background: var(--accent); - color: var(--background); - border-color: var(--accent); -} - -.encrypted-indicator { - font-size: 11px; - font-weight: 500; - opacity: 0.7; - margin-bottom: 4px; -} - -.encrypted-preview { - font-size: 13px; - font-family: 'Courier New', monospace; - opacity: 0.8; -} - -.message-meta { - display: flex; - align-items: center; - gap: 8px; - font-size: 11px; - color: var(--foreground-tertiary); - padding: 0 4px; -} - -.delivery-status { - color: var(--foreground-secondary); -} - -/* Chat Input */ -.chat-input { - display: flex; - gap: 12px; - padding: 20px 24px; - border-top: 1px solid var(--border); - background: var(--background-secondary); -} - -.chat-input input { - flex: 1; - padding: 12px 16px; - border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: var(--background); - color: var(--foreground); - font-size: 14px; - outline: none; - transition: border-color 0.15s ease; -} - -.chat-input input:focus { - border-color: var(--accent); -} - -.chat-input input::placeholder { - color: var(--foreground-tertiary); -} - -.send-button { - background: var(--accent); - color: var(--background); - border: none; -} - -.send-button:hover:not(:disabled) { - background: var(--accent-hover); -} - -.send-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Empty States */ -.chat-empty-state { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - padding: 48px 24px; - text-align: center; - color: var(--foreground-tertiary); -} - -.chat-empty-state svg { - opacity: 0.3; -} - -.chat-empty-state h2, -.chat-empty-state h3 { - font-size: 18px; - font-weight: 600; - color: var(--foreground-secondary); - margin: 0; -} - -.chat-empty-state p { - font-size: 14px; - color: var(--foreground-tertiary); - margin: 0; - max-width: 320px; -} - -.chat-loading { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - padding: 48px 24px; -} - -.spinner { - width: 32px; - height: 32px; - border: 3px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* Modal */ -.modal-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.8); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - padding: 24px; -} - -.modal-content { - background: var(--background-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 32px; - max-width: 480px; - width: 100%; -} - -.modal-content h2 { - font-size: 20px; - font-weight: 600; - margin: 0 0 8px 0; -} - -.modal-content p { - font-size: 14px; - color: var(--foreground-secondary); - margin: 0 0 24px 0; -} - -.modal-content form { - display: flex; - flex-direction: column; - gap: 16px; -} - -.modal-content input { - padding: 12px 16px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--background); - color: var(--foreground); - font-size: 14px; - outline: none; - transition: border-color 0.15s ease; -} - -.modal-content input:focus { - border-color: var(--accent); -} - -.modal-actions { - display: flex; - gap: 12px; - justify-content: flex-end; -} - -/* Buttons */ -.btn-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - border-radius: var(--radius-md); - border: 1px solid var(--border); - background: transparent; - color: var(--foreground); - cursor: pointer; - transition: all 0.15s ease; -} - -.btn-icon:hover:not(:disabled) { - background: var(--background-tertiary); - border-color: var(--border-hover); -} - -.btn-primary { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 10px 20px; - font-size: 14px; - font-weight: 500; - border-radius: var(--radius-md); - border: none; - background: var(--accent); - color: var(--background); - cursor: pointer; - transition: all 0.15s ease; -} - -.btn-primary:hover:not(:disabled) { - background: var(--accent-hover); -} - -.btn-primary:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.btn-secondary { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 10px 20px; - font-size: 14px; - font-weight: 500; - border-radius: var(--radius-md); - border: 1px solid var(--border); - background: transparent; - color: var(--foreground); - cursor: pointer; - transition: all 0.15s ease; -} - -.btn-secondary:hover { - background: var(--background-tertiary); - border-color: var(--border-hover); -} - -/* Mobile Responsive */ -@media (max-width: 768px) { - .chat-container { - grid-template-columns: 1fr; - border-radius: 0; - border-left: none; - border-right: none; - } - - .mobile-hidden { - display: none; - } - - .back-button { - display: inline-flex; - } - - .chat-sidebar { - border-right: none; - } - - .message { - max-width: 85%; - } -} diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx deleted file mode 100644 index 8c96260..0000000 --- a/src/app/chat/page.tsx +++ /dev/null @@ -1,193 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import { useAuth } from '@/lib/contexts/AuthContext'; -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; - chatPublicKey: string | null; - }; - lastMessageAt: string; - lastMessagePreview: string; - unreadCount: number; -} - -interface Message { - id: string; - senderHandle: string; - senderDisplayName?: string; - senderAvatarUrl?: string; - senderPublicKey?: string; - encryptedContent: string; - decryptedContent?: string; - isSentByMe: boolean; - deliveredAt?: string; - readAt?: string; - createdAt: string; -} - -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([]); - const [newMessage, setNewMessage] = useState(''); - const [newChatHandle, setNewChatHandle] = useState(''); - const [showNewChat, setShowNewChat] = useState(false); - 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 && 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]); - - 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 { } finally { setLoading(false); } }; - const loadMessages = async (conversationId: string) => { - console.log('[LoadMessages] Starting load for conversation:', conversationId); - try { - // Ensure we have the recipient's key before trying to decrypt - let chatPartnerKey = selectedConversation?.participant2?.chatPublicKey || recipientPublicKey; - - console.log('[LoadMessages] Initial chatPartnerKey:', !!chatPartnerKey); - - // If we don't have the key yet, fetch it - if (!chatPartnerKey && selectedConversation?.participant2?.handle) { - console.log('[LoadMessages] Fetching recipient key for:', selectedConversation.participant2.handle); - try { - const userRes = await fetch(`/api/users/${encodeURIComponent(selectedConversation.participant2.handle)}`); - const userData = await userRes.json(); - chatPartnerKey = userData.user?.chatPublicKey || null; - console.log('[LoadMessages] Fetched chatPartnerKey:', !!chatPartnerKey); - if (chatPartnerKey) setRecipientPublicKey(chatPartnerKey); - } catch (e) { - console.error('[LoadMessages] Failed to fetch recipient key:', e); - } - } - - const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`); - const data = await res.json(); - - console.log('[LoadMessages] Received messages:', data.messages?.length || 0); - - const decrypted = await Promise.all((data.messages || []).map(async (msg: Message & { isE2E?: boolean }, idx: number) => { - console.log(`[LoadMessages] Processing message ${idx}:`, { - id: msg.id, - isSentByMe: msg.isSentByMe, - hasSenderPublicKey: !!msg.senderPublicKey, - isE2EFlag: msg.isE2E - }); - - try { - // Check if this is an E2E encrypted message (has senderPublicKey) - // or a legacy RSA message (no senderPublicKey) - const isE2E = !!msg.senderPublicKey; - - if (!isE2E) { - console.log(`[LoadMessages] Message ${idx} is legacy RSA`); - // Legacy RSA message - can't decrypt client-side - return { ...msg, decryptedContent: '[Legacy encrypted message]' }; - } - - // ECDH: Both parties derive same shared secret - // For messages I sent: use recipient's public key (chatPartnerKey) - // For messages I received: use sender's public key - const otherPartyKey = msg.isSentByMe ? chatPartnerKey : msg.senderPublicKey; - - console.log(`[LoadMessages] Message ${idx} otherPartyKey:`, !!otherPartyKey, 'isSentByMe:', msg.isSentByMe); - - if (!otherPartyKey) { - console.warn('Missing key for decryption. isSentByMe:', msg.isSentByMe, 'chatPartnerKey:', !!chatPartnerKey); - return { ...msg, decryptedContent: '[Missing decryption key]' }; - } - - if (msg.encryptedContent) { - console.log(`[LoadMessages] Calling decryptMessage for message ${idx}`); - const decrypted = await decryptMessage(msg.encryptedContent, otherPartyKey); - console.log(`[LoadMessages] Message ${idx} decrypted successfully`); - return { ...msg, decryptedContent: decrypted }; - } - } catch (err) { - console.error(`[LoadMessages] Decrypt error for message ${idx}:`, err, 'msg:', msg.id, 'isSentByMe:', msg.isSentByMe); - } - return { ...msg, decryptedContent: '[Unable to decrypt]' }; - })); - - console.log('[LoadMessages] Setting messages:', decrypted.length); - setMessages(decrypted); - } catch (err) { - console.error('[LoadMessages] Load messages error:', err); - } - }; - 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 { } }; - 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 { } 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 { } 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())); - - if (!user) return

Sign in to use Swarm Chat

End-to-end encrypted messaging

; - if (!isReady) return

Loading encryption...

; - if (!hasKeys) return ( -
-
- -

{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.

- -
- {showPasswordModal && ( -
setShowPasswordModal(false)}> -
e.stopPropagation()}> -

{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}

} -
- - -
-
-
-
- )} -
- ); - - 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/app/globals.css b/src/app/globals.css index d3e75ab..42268b4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -240,6 +240,7 @@ a.btn-primary:visited { margin: 0 auto; } + .sidebar { width: 240px; flex-shrink: 0; diff --git a/src/components/ChatWidget.tsx b/src/components/ChatWidget.tsx new file mode 100644 index 0000000..dfa0380 --- /dev/null +++ b/src/components/ChatWidget.tsx @@ -0,0 +1,511 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useAuth } from '@/lib/contexts/AuthContext'; +import { useChatEncryption } from '@/lib/hooks/useChatEncryption'; +import { MessageCircle, Send, ArrowLeft, Search, Plus, Lock, Shield, Key, X, ChevronDown, CheckCheck, Loader2, Mail } from 'lucide-react'; +import { formatFullHandle } from '@/lib/utils/handle'; +import Link from 'next/link'; + +interface Conversation { + id: string; + participant2: { + handle: string; + displayName: string; + avatarUrl: string | null; + chatPublicKey: string | null; + }; + lastMessageAt: string; + lastMessagePreview: string; + unreadCount: number; +} + +interface Message { + id: string; + senderHandle: string; + senderDisplayName?: string; + senderAvatarUrl?: string; + senderPublicKey?: string; + encryptedContent: string; + decryptedContent?: string; + isSentByMe: boolean; + deliveredAt?: string; + readAt?: string; + createdAt: string; +} + +export function ChatWidget() { + const { user } = useAuth(); + const { keys, isReady, hasKeys, isRegistering, needsPasswordToRestore, generateAndRegisterKeys, restoreKeysWithPassword, encryptMessage, decryptMessage } = useChatEncryption(); + + // Widget State + const [isOpen, setIsOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(true); // For minimize/maximize behavior when open + + // Chat Data State + const [conversations, setConversations] = useState([]); + const [selectedConversation, setSelectedConversation] = useState(null); + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [newChatHandle, setNewChatHandle] = useState(''); + const [showNewChat, setShowNewChat] = useState(false); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [recipientPublicKey, setRecipientPublicKey] = useState(null); + + // Password/Key State + const [showPasswordInput, setShowPasswordInput] = useState(false); + const [password, setPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [isProcessingPassword, setIsProcessingPassword] = useState(false); + + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + + // Listen for global open event + useEffect(() => { + const handleOpenEvent = () => { + setIsOpen(true); + setIsExpanded(true); + }; + window.addEventListener('open-chat-widget', handleOpenEvent); + return () => window.removeEventListener('open-chat-widget', handleOpenEvent); + }, []); + + // Load conversations when widget opens or auth changes + useEffect(() => { + if (user && hasKeys && isOpen) { + loadConversations(); + } + }, [user, hasKeys, isOpen]); + + // Load messages when conversation is selected + useEffect(() => { + if (selectedConversation && hasKeys) { + loadMessages(selectedConversation.id); + markAsRead(selectedConversation.id); + fetchRecipientKey(selectedConversation.participant2.handle); + } + }, [selectedConversation, hasKeys]); + + // Auto-scroll to bottom of messages + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [messages, isOpen, isExpanded]); + + // -- Data Fetching Utils (Ported from page.tsx) -- + 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 () => { + setLoading(true); + try { + const res = await fetch('/api/swarm/chat/conversations'); + const data = await res.json(); + setConversations(data.conversations || []); + } catch (e) { + console.error("Failed to load conversations", e); + } finally { + setLoading(false); + } + }; + + const loadMessages = async (conversationId: string) => { + try { + // Ensure we have the recipient's key logic (simplified for brevity, main logic preserved) + let chatPartnerKey = selectedConversation?.participant2?.chatPublicKey || recipientPublicKey; + + if (!chatPartnerKey && selectedConversation?.participant2?.handle) { + try { + const userRes = await fetch(`/api/users/${encodeURIComponent(selectedConversation.participant2.handle)}`); + const userData = await userRes.json(); + chatPartnerKey = userData.user?.chatPublicKey || null; + if (chatPartnerKey) setRecipientPublicKey(chatPartnerKey); + } catch (e) { console.error(e); } + } + + 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 & { isE2E?: boolean }) => { + try { + const isE2E = !!msg.senderPublicKey; + if (!isE2E) return { ...msg, decryptedContent: '[Legacy encrypted message]' }; + + const otherPartyKey = msg.isSentByMe ? chatPartnerKey : msg.senderPublicKey; + if (!otherPartyKey) return { ...msg, decryptedContent: '[Missing decryption key]' }; + + if (msg.encryptedContent) { + const decrypted = await decryptMessage(msg.encryptedContent, otherPartyKey); + return { ...msg, decryptedContent: decrypted }; + } + } catch (err) { } + return { ...msg, decryptedContent: '[Unable to decrypt]' }; + })); + + setMessages(decrypted); + } catch (err) { console.error(err); } + }; + + 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 { } }; + + 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) { + setNewMessage(''); + await loadMessages(selectedConversation.id); + loadConversations(); + } + } catch (err) { console.error(err); } + 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 { } + 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); + } + setShowPasswordInput(false); + setPassword(''); + } catch (err) { + setPasswordError('Failed. Please try again.'); + } finally { + setIsProcessingPassword(false); + } + }; + + const filteredConversations: Conversation[] = conversations.filter((conv: Conversation) => conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) || conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase())); + + // -- Render Logic -- + + // If not signed in, show nothing or maybe a prompt? User said "widget", usually hidden if not meaningful. + if (!user) return null; + + // Collapsed State (Just the Fab/Button) + if (!isOpen) { + return ( +
+ +
+ ); + } + + // Expanded Widget + return ( +
+ + {/* Header */} +
setIsExpanded(!isExpanded)} + > +
+ {selectedConversation && isExpanded ? ( + + ) : null} +

Messages

+
+
+ {!selectedConversation && isExpanded && ( + + )} + + +
+
+ + {/* Content (Only visible if expanded) */} + {isExpanded && ( +
+ + {/* Encryption Setup Overlay */} + {!hasKeys && ( +
+ +

{needsPasswordToRestore ? 'Unlock Messages' : 'Enable Encryption'}

+

{needsPasswordToRestore ? 'Enter your password to restore chat.' : 'Set up secure messaging.'}

+ + {!showPasswordInput ? ( + + ) : ( +
+ setPassword(e.target.value)} + autoFocus + /> + {passwordError &&

{passwordError}

} + +
+ )} +
+ )} + + {/* Main View Switching */} + {hasKeys && ( + <> + {selectedConversation ? ( + // Thread View +
+ {/* Thread Header */} +
+
+ {selectedConversation.participant2.avatarUrl ? ( + + ) : ( + {selectedConversation.participant2.displayName[0]} + )} +
+
+

{selectedConversation.participant2.displayName}

+

{formatFullHandle(selectedConversation.participant2.handle)}

+
+
+ + {/* Messages List */} +
+
+
+ End-to-end encrypted +
+
+ + {messages.map(msg => ( +
+ {/* Avatar for received messages */} + {!msg.isSentByMe && ( +
+ {msg.senderAvatarUrl ? : {msg.senderDisplayName?.[0]}} +
+ )} + +
+
+ {msg.decryptedContent || msg.encryptedContent} +
+
+ {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {msg.isSentByMe && ( + + {msg.readAt ? : msg.deliveredAt ? : null} + + )} +
+
+
+ ))} +
+
+ + {/* Input Area */} +
+ setNewMessage(e.target.value)} + /> + +
+
+ ) : ( + // Conversation List View +
+ {showNewChat ? ( +
+
+ setNewChatHandle(e.target.value)} + autoFocus + /> +
+ + +
+
+
+ ) : ( + <> + {/* Search Bar */} +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + {/* List */} +
+ {loading ? ( +
+ ) : filteredConversations.length === 0 ? ( +
+

Welcome to your inbox!

+

Drop a potential swarm of thoughts to people.

+ +
+ ) : ( + filteredConversations.map((conv: any) => ( +
setSelectedConversation(conv)} + className={`flex items-center gap-3 p-3 hover:bg-[#161616] cursor-pointer transition-colors ${selectedConversation?.id === conv.id ? 'bg-[#161616] border-r-2 border-blue-500' : ''}`} + > +
+
+ {conv.participant2.avatarUrl ? ( + + ) : ( + {conv.participant2.displayName[0]} + )} +
+
+
+
+ {conv.participant2.displayName} + {formatFullHandle(conv.participant2.handle)} +
+
+ 0 ? 'text-white font-medium' : 'text-gray-500'}`}> + {conv.lastMessagePreview} + + {conv.unreadCount > 0 && ( + + {conv.unreadCount} + + )} +
+
+
+ )) + )} +
+ + )} +
+ )} + + )} + +
+ )} +
+ ); +} diff --git a/src/components/LayoutWrapper.tsx b/src/components/LayoutWrapper.tsx index 70e3b8d..abb7037 100644 --- a/src/components/LayoutWrapper.tsx +++ b/src/components/LayoutWrapper.tsx @@ -4,6 +4,7 @@ import { usePathname } from 'next/navigation'; import { Sidebar } from './Sidebar'; import { RightSidebar } from './RightSidebar'; import { useAuth } from '@/lib/contexts/AuthContext'; +import { ChatWidget } from './ChatWidget'; export function LayoutWrapper({ children }: { children: React.ReactNode }) { const { loading } = useAuth(); @@ -50,12 +51,13 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { } return ( -
+
{children}
{!hideRightSidebar && } +
); } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index df061b1..ad0572c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -31,14 +31,14 @@ export function Sidebar() { // Fetch unread notification count useEffect(() => { if (!user) return; - + const fetchUnread = () => { fetch('/api/notifications?unread=true&limit=50') .then(res => res.json()) .then(data => { setUnreadCount(data.notifications?.length || 0); }) - .catch(() => {}); + .catch(() => { }); }; fetchUnread(); @@ -52,7 +52,7 @@ export function Sidebar() { const handleLogout = async () => { if (loggingOut) return; - + setLoggingOut(true); try { await fetch('/api/auth/logout', { method: 'POST' }); @@ -101,12 +101,16 @@ export function Sidebar() { )} {user && ( - + )} {user && ( @@ -163,8 +167,8 @@ export function Sidebar() { onClick={handleLogout} disabled={loggingOut} className="btn btn-ghost" - style={{ - width: '100%', + style={{ + width: '100%', justifyContent: 'flex-start', gap: '12px', padding: '10px 12px', diff --git a/src/lib/crypto/client-crypto.ts b/src/lib/crypto/client-crypto.ts index c36a9e5..a098120 100644 --- a/src/lib/crypto/client-crypto.ts +++ b/src/lib/crypto/client-crypto.ts @@ -147,22 +147,32 @@ export async function decryptMessage( myPrivateKeyBase64: string, theirPublicKeyBase64: string ): Promise { - const myPrivateKey = await importPrivateKey(myPrivateKeyBase64); - const theirPublicKey = await importPublicKey(theirPublicKeyBase64); - const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey); + try { + 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 combined = base64ToBuffer(encryptedMessage); - const decrypted = await window.crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - sharedKey, - ciphertext - ); + if (combined.byteLength < 12) { + throw new Error('Message too short'); + } - const decoder = new TextDecoder(); - return decoder.decode(decrypted); + 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); + } catch (error) { + console.error('Decryption failed:', error); + return '[Message cannot be decrypted]'; + } } // Utility functions @@ -176,10 +186,33 @@ function bufferToBase64(buffer: ArrayBuffer): string { } 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); + // Gracefull handle null/undefined + if (!base64) return new ArrayBuffer(0); + + // Check for JSON (legacy format) + if (base64.trim().startsWith('{')) { + console.warn('[base64ToBuffer] Detected JSON instead of Base64, returning empty buffer'); + throw new Error('Invalid message format: JSON detected'); + } + + // Clean the string: + // 1. Remove newlines/tabs (formatting) + // 2. Replace spaces with '+' (common URL decoding error where + becomes space) + // 3. Handle URL-safe chars (- -> +, _ -> /) + const cleaned = base64.replace(/[\n\r\t]/g, '') + .replace(/ /g, '+') + .replace(/-/g, '+') + .replace(/_/g, '/'); + + try { + const binary = atob(cleaned); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } catch (e) { + console.error('[base64ToBuffer] Failed to decode base64:', e); + throw new Error(`Failed to decode base64: ${e instanceof Error ? e.message : String(e)}`); } - return bytes.buffer; } diff --git a/src/lib/hooks/useChatEncryption.ts b/src/lib/hooks/useChatEncryption.ts index 8b416a7..433c2a1 100644 --- a/src/lib/hooks/useChatEncryption.ts +++ b/src/lib/hooks/useChatEncryption.ts @@ -37,7 +37,7 @@ export function useChatEncryption() { // 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); @@ -50,7 +50,7 @@ export function useChatEncryption() { 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); @@ -59,7 +59,7 @@ export function useChatEncryption() { } catch (error) { console.error('Failed to check server keys:', error); } - + setIsReady(true); }; @@ -109,27 +109,34 @@ export function useChatEncryption() { // 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 + // Register public key + encrypted private key backup with server FIRST const response = await fetch('/api/chat/keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + body: JSON.stringify({ chatPublicKey: publicKey, chatPrivateKeyEncrypted: encryptedPrivateKey, }), }); if (!response.ok) { - throw new Error('Failed to register chat keys'); + const error = await response.json(); + console.error('[GenerateKeys] Server registration failed:', error); + throw new Error(error.error || 'Failed to register chat keys'); } + // Only save to localStorage AFTER server confirms + localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey); + localStorage.setItem(PUBLIC_KEY_STORAGE, publicKey); + setKeys({ publicKey, privateKey }); setNeedsPasswordToRestore(false); + + console.log('[GenerateKeys] Keys generated and registered successfully'); return { publicKey, privateKey }; + } catch (error) { + console.error('[GenerateKeys] Failed:', error); + throw error; } finally { setIsRegistering(false); } @@ -171,40 +178,51 @@ export function useChatEncryption() { encryptedMessage: string, senderPublicKey: string ): Promise => { - if (!keys?.privateKey) { - console.error('[Decrypt] No private key available'); - throw new Error('No chat keys available'); + try { + if (!keys?.privateKey) { + console.error('[Decrypt] No private key available'); + throw new Error('No chat keys available'); + } + + console.log('[Decrypt] Starting decryption', { + hasPrivateKey: !!keys.privateKey, + hasSenderPublicKey: !!senderPublicKey, + encryptedLength: encryptedMessage.length + }); + + const myPrivateKey = await importPrivateKey(keys.privateKey); + const theirPublicKey = await importPublicKey(senderPublicKey); + const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey); + + const combined = base64ToBuffer(encryptedMessage); + + if (combined.byteLength < 12) { + throw new Error('Message too short (invalid ciphertext)'); + } + + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + + console.log('[Decrypt] Decrypting with shared key', { + ivLength: iv.byteLength, + ciphertextLength: ciphertext.byteLength + }); + + const decrypted = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + sharedKey, + ciphertext + ); + + const decoder = new TextDecoder(); + const result = decoder.decode(decrypted); + console.log('[Decrypt] Success:', result.substring(0, 50)); + return result; + } catch (error) { + console.error('[Decrypt] Failed:', error); + // Return a safe placeholder that won't crash the UI + return '[Message cannot be decrypted]'; } - - console.log('[Decrypt] Starting decryption', { - hasPrivateKey: !!keys.privateKey, - hasSenderPublicKey: !!senderPublicKey, - encryptedLength: encryptedMessage.length - }); - - 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); - - console.log('[Decrypt] Decrypting with shared key', { - ivLength: iv.byteLength, - ciphertextLength: ciphertext.byteLength - }); - - const decrypted = await window.crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - sharedKey, - ciphertext - ); - - const decoder = new TextDecoder(); - const result = decoder.decode(decrypted); - console.log('[Decrypt] Success:', result.substring(0, 50)); - return result; }, [keys]); // Clear keys (on logout) @@ -236,7 +254,7 @@ async function encryptPrivateKeyWithPassword(privateKey: string, password: strin 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', @@ -245,7 +263,7 @@ async function encryptPrivateKeyWithPassword(privateKey: string, password: strin false, ['deriveKey'] ); - + const aesKey = await window.crypto.subtle.deriveKey( { name: 'PBKDF2', @@ -258,14 +276,14 @@ async function encryptPrivateKeyWithPassword(privateKey: string, password: strin 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), @@ -278,7 +296,7 @@ async function decryptPrivateKeyWithPassword(encryptedData: string, password: st 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', @@ -287,7 +305,7 @@ async function decryptPrivateKeyWithPassword(encryptedData: string, password: st false, ['deriveKey'] ); - + const aesKey = await window.crypto.subtle.deriveKey( { name: 'PBKDF2', @@ -300,14 +318,14 @@ async function decryptPrivateKeyWithPassword(encryptedData: string, password: st false, ['decrypt'] ); - + // Decrypt const decrypted = await window.crypto.subtle.decrypt( { name: 'AES-GCM', iv: base64ToBuffer(iv) }, aesKey, base64ToBuffer(ciphertext) ); - + return decoder.decode(decrypted); } @@ -364,10 +382,35 @@ function bufferToBase64(buffer: ArrayBuffer): string { } 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); + // Gracefull handle null/undefined + if (!base64) return new ArrayBuffer(0); + + // Check for JSON (legacy format) + if (base64.trim().startsWith('{')) { + console.warn('[base64ToBuffer] Detected JSON instead of Base64, returning empty buffer'); + throw new Error('Invalid message format: JSON detected'); + } + + // Clean the string: + // 1. Remove newlines/tabs (formatting) + // 2. Replace spaces with '+' (common URL decoding error where + becomes space) + // 3. Handle URL-safe chars (- -> +, _ -> /) + const cleaned = base64.replace(/[\n\r\t]/g, '') + .replace(/ /g, '+') + .replace(/-/g, '+') + .replace(/_/g, '/'); + + try { + const binary = atob(cleaned); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } catch (e) { + console.error('[base64ToBuffer] Failed to decode base64:', e); + // Return empty buffer or throw specific error? + // Throwing allows decryptMessage to catch and return placeholder + throw new Error(`Failed to decode base64: ${e instanceof Error ? e.message : String(e)}`); } - return bytes.buffer; }