diff --git a/src/app/.well-known/synapsis/chat/[did]/route.ts b/src/app/.well-known/synapsis/chat/[did]/route.ts new file mode 100644 index 0000000..db0030d --- /dev/null +++ b/src/app/.well-known/synapsis/chat/[did]/route.ts @@ -0,0 +1,47 @@ + +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { chatDeviceBundles } from '@/db/schema'; +import { eq } from 'drizzle-orm'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ did: string }> } +) { + const { did } = await params; + + // 1. Fetch all devices for this DID + const bundles = await db.query.chatDeviceBundles.findMany({ + where: eq(chatDeviceBundles.did, did), + with: { + oneTimeKeys: { + limit: 5, // Return a few keys; client picks one + // Ideally we pick non-conflicted ones or random ones + } + } + }); + + if (!bundles || bundles.length === 0) { + return NextResponse.json([], { status: 404 }); + } + + // 2. Format Response + const response = bundles.map(b => ({ + did: b.did, + deviceId: b.deviceId, + identityKey: b.identityKey, // Base64 X25519 + signedPreKey: JSON.parse(b.signedPreKey), + oneTimeKeys: b.oneTimeKeys.map(k => ({ + id: k.keyId, + key: k.publicKey + })), + signature: b.signature // ECDSA signature of this bundle + })); + + return NextResponse.json(response, { + headers: { + 'Access-Control-Allow-Origin': '*', // Federation Header + 'Cache-Control': 'max-age=60' // Cache for 1 min + } + }); +} diff --git a/src/app/api/chat/inbox/route.ts b/src/app/api/chat/inbox/route.ts new file mode 100644 index 0000000..4044f11 --- /dev/null +++ b/src/app/api/chat/inbox/route.ts @@ -0,0 +1,60 @@ + +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { chatInbox } from '@/db/schema'; +import { eq, and, or, isNull } from 'drizzle-orm'; +import { getSession } from '@/lib/auth'; + +/** + * GET /api/chat/inbox + * Poll for new encrypted envelopes for this device. + */ +export async function GET(request: NextRequest) { + try { + const session = await getSession(); + if (!session?.user?.did) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { did } = session.user; + const { searchParams } = new URL(request.url); + const deviceId = searchParams.get('deviceId'); + + if (!deviceId) { + return NextResponse.json({ error: 'Device ID required' }, { status: 400 }); + } + + // Fetch messages for this user AND (this device OR all devices) + const messages = await db.select().from(chatInbox).where( + and( + eq(chatInbox.recipientDid, did), + or( + eq(chatInbox.recipientDeviceId, deviceId), + isNull(chatInbox.recipientDeviceId) // Broadcasts (if any) + ), + eq(chatInbox.isRead, false) + ) + ); + + // TODO: Mark them as read immediately? + // Or client must ACK? + // For now, we return them. Client deals with idempotency. + // If we mark read, checking on another tab might miss them if race condition. + // But uniqueness is (id). + + return NextResponse.json({ messages }); + + } catch (error: any) { + console.error('Inbox poll fail:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * DELETE /api/chat/inbox + * Acknowledge/Delete processed messages + */ +export async function DELETE(request: NextRequest) { + // ... Implement ACK cleaning + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/chat/keys/route.ts b/src/app/api/chat/keys/route.ts index 99a0dfc..82b5298 100644 --- a/src/app/api/chat/keys/route.ts +++ b/src/app/api/chat/keys/route.ts @@ -1,95 +1,79 @@ -/** - * 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 }); - } -} +import { db } from '@/db'; +import { chatDeviceBundles, chatOneTimeKeys } from '@/db/schema'; +import { requireSignedAction } from '@/lib/auth/verify-signature'; +import { eq, and } from 'drizzle-orm'; +/** + * POST /api/chat/keys + * Publish a new Device Bundle and OTKs. + * + * Payload: SignedAction < { + * deviceId: string, + * identityKey: string, + * signedPreKey: { id, key, sig }, + * signature: string, (The ECDSA signature of the bundle itself) + * oneTimeKeys: { id, key }[] + * } > + */ export async function POST(request: NextRequest) { try { - const session = await getSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const body = await request.json(); - const { chatPublicKey, chatPrivateKeyEncrypted } = await request.json(); + // 1. Verify User Identity (ECDSA Root) + // The wrapper itself is a SignedAction with action="chat.keys.publish" + // This proves the user (DID) is authorizing this device registration. + const user = await requireSignedAction(body); - if (!chatPublicKey || typeof chatPublicKey !== 'string') { - return NextResponse.json({ error: 'chatPublicKey required' }, { status: 400 }); - } + const { deviceId, identityKey, signedPreKey, signature, oneTimeKeys } = body.data; - // Validate it looks like a base64 SPKI key (should be ~120 chars for P-256) - if (chatPublicKey.length < 100 || chatPublicKey.length > 200) { - console.error('[Chat Keys API] Invalid public key length:', chatPublicKey.length); - return NextResponse.json({ - error: `Invalid public key format: expected ~120 characters, got ${chatPublicKey.length}` - }, { status: 400 }); - } + // 2. Validate Bundle Signature (The bundle.signature must cover the fields) + // Actually, requireSignedAction already verified the payload signature. + // The "signature" field inside data is redundant if the whole thing is signed by DID? + // Or is "signature" the signature of the bundle bytes? + // In our design, the SignedAction *is* the signature. + // So "signature" inside might be the "Self-Signature" of the X25519 Identity Key signing the structure? + // No, standard Signal: The Bundle is signed by Identity Key. + // Here, Identity Key is ECDSA. The SignedAction covers it. + // So we just trust the SignedAction. - // Additional validation: try to decode as base64 - try { - const decoded = Buffer.from(chatPublicKey, 'base64'); - if (decoded.length < 80 || decoded.length > 100) { - console.error('[Chat Keys API] Invalid decoded key size:', decoded.length); - return NextResponse.json({ - error: `Invalid public key: decoded size ${decoded.length} bytes (expected ~91 bytes for P-256)` - }, { status: 400 }); + // 3. Upsert Bundle + await db.transaction(async (tx) => { + // Upsert device bundle + await tx.delete(chatDeviceBundles).where( + and( + eq(chatDeviceBundles.userId, user.id), + eq(chatDeviceBundles.deviceId, deviceId) + ) + ); + + const [bundle] = await tx.insert(chatDeviceBundles).values({ + userId: user.id, + did: user.did, + deviceId, + identityKey, + signedPreKey: JSON.stringify(signedPreKey), + signature, // We store the action signature or the explicit inner signature provided + }).returning(); + + // 4. Insert OTKs + if (oneTimeKeys && oneTimeKeys.length > 0) { + await tx.insert(chatOneTimeKeys).values( + oneTimeKeys.map((k: any) => ({ + userId: user.id, + bundleId: bundle.id, + keyId: k.id, + publicKey: k.key + })) + ).onConflictDoNothing(); } - } catch (e) { - console.error('[Chat Keys API] Failed to decode public key as base64:', e); - return NextResponse.json({ error: 'Public key is not valid base64' }, { 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 }); - } + return NextResponse.json({ success: true, count: oneTimeKeys?.length || 0 }); - 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 }); + } catch (error: any) { + console.error('Failed to publish keys:', error); + return NextResponse.json({ error: error.message }, { status: 400 }); } } diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts new file mode 100644 index 0000000..b12214e --- /dev/null +++ b/src/app/api/chat/send/route.ts @@ -0,0 +1,44 @@ + +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { chatInbox } from '@/db/schema'; +import { requireSignedAction } from '@/lib/auth/verify-signature'; + +/** + * POST /api/chat/send + * Deliver an encrypted envelope to a local user's inbox. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // 1. Verify Envelope Signature (Anti-Spoofing & Replay) + const user = await requireSignedAction(body); + + const { action, data } = body; + if (action !== 'chat.deliver') { + return NextResponse.json({ error: 'Invalid action type' }, { status: 400 }); + } + + const { recipientDid, recipientDeviceId, ciphertext } = data; + + if (!recipientDid || !ciphertext) { + return NextResponse.json({ error: 'Missing required delivery fields' }, { status: 400 }); + } + + // 2. Insert into Inbox + await db.insert(chatInbox).values({ + senderDid: user.did, + recipientDid, + recipientDeviceId: recipientDeviceId || null, // Null = broadcast/all? Or specific? V2.1 says per-device. + envelope: JSON.stringify(body), + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days TTL + }); + + return NextResponse.json({ success: true }); + + } catch (error: any) { + console.error('Delivery failed:', error); + return NextResponse.json({ error: error.message }, { status: 400 }); + } +} diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 91d85c0..1d63f82 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,974 +1,265 @@ + 'use client'; import { useState, useEffect, useRef } from 'react'; import { useAuth } from '@/lib/contexts/AuthContext'; import { useChatEncryption } from '@/lib/hooks/useChatEncryption'; -import { ArrowLeft, Send, Lock, Shield, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react'; +import { ArrowLeft, Send, Shield, Loader2, MessageCircle, Search, Plus, Trash2 } from 'lucide-react'; import { formatFullHandle } from '@/lib/utils/handle'; import { useRouter } from 'next/navigation'; -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; +interface ChatMessage { + id: string; // The ID of the envelope or internal ID + senderDid: string; + senderHandle?: string; // Resolved if possible + content: string; // Decrypted + timestamp: number; + k: string; // React key + isMe: boolean; } export default function ChatPage() { const { user } = useAuth(); const router = useRouter(); - const { keys, isReady, hasKeys, needsPasswordToRestore, generateAndRegisterKeys, restoreKeysWithPassword, encryptMessage, decryptMessage } = useChatEncryption(); + const { isReady, status, ensureReady, sendMessage, decryptMessage } = useChatEncryption(); - // Chat Data State - const [conversations, setConversations] = useState([]); - const [selectedConversation, setSelectedConversation] = useState(null); - const [messages, setMessages] = useState([]); + // UI State + 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 [showDeleteModal, setShowDeleteModal] = useState(false); - const [conversationToDelete, setConversationToDelete] = useState(null); - const [isDeleting, setIsDeleting] = useState(false); - // Password/Key State - const [showPasswordInput, setShowPasswordInput] = useState(false); - const [password, setPassword] = useState(''); - const [passwordError, setPasswordError] = useState(''); - const [isProcessingPassword, setIsProcessingPassword] = useState(false); + // Conversation State (Simplified for V2 Migration) + // We group messages by DID. + const [activeDid, setActiveDid] = useState(null); + const [handles, setHandles] = useState>({}); // DID -> handle - // Encryption loading state to prevent flash - const [encryptionChecked, setEncryptionChecked] = useState(false); + const [showNewChat, setShowNewChat] = useState(false); + const [newChatHandle, setNewChatHandle] = useState(''); - const messagesEndRef = useRef(null); - const messagesContainerRef = useRef(null); - const [isAtBottom, setIsAtBottom] = useState(true); - - // Check if user is scrolled to bottom - const checkIfAtBottom = () => { - if (!messagesContainerRef.current) return true; - const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current; - const threshold = 100; // pixels from bottom - return scrollHeight - scrollTop - clientHeight < threshold; - }; - - // Handle scroll to track if user is at bottom - const handleScroll = () => { - setIsAtBottom(checkIfAtBottom()); - }; - - // Scroll to bottom manually - const scrollToBottom = () => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }; - - // Wait for encryption to be ready before showing UI - useEffect(() => { - if (isReady) { - setEncryptionChecked(true); - } - }, [isReady]); + // Encryption Status + // status can be: idle, initializing, ready, error, generating_keys // Redirect if not logged in useEffect(() => { if (!user) { - router.push('/login'); + // router.push('/login'); // Handled by Layout generally, but safe here } }, [user, router]); - // Load conversations + // Polling Inbox useEffect(() => { - if (user && hasKeys) { - loadConversations(true); // Initial load with spinner - - // Poll for new conversations every 5 seconds (no spinner) - const pollInterval = setInterval(() => { - loadConversations(false); - }, 5000); - - return () => clearInterval(pollInterval); - } - }, [user, hasKeys]); + if (!isReady || !user) return; - // Load messages when conversation is selected - useEffect(() => { - if (selectedConversation && hasKeys) { - loadMessages(selectedConversation.id); - markAsRead(selectedConversation.id); - fetchRecipientKey(selectedConversation.participant2.handle); - - // Poll for new messages every 3 seconds - const pollInterval = setInterval(() => { - loadMessages(selectedConversation.id); - }, 3000); - - return () => clearInterval(pollInterval); - } - }, [selectedConversation, hasKeys]); + const deviceId = localStorage.getItem('synapsis_device_id'); + if (!deviceId) return; - // Auto-scroll to bottom of messages only if user was already at bottom - useEffect(() => { - if (messagesEndRef.current && isAtBottom) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }, [messages, isAtBottom]); + const poll = async () => { + try { + const res = await fetch(`/api/chat/inbox?deviceId=${deviceId}`); + if (res.ok) { + const data = await res.json(); + if (data.messages && data.messages.length > 0) { + for (const msg of data.messages) { + // Decrypt + const envelope = JSON.parse(msg.envelope); // The SignedAction + const plaintext = await decryptMessage(envelope); - 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); - } - }; + // Add to list if valid + const senderDid = envelope.did; + const handle = envelope.handle; // Sender handle in action - const loadConversations = async (isInitialLoad = false) => { - if (isInitialLoad) 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 { - if (isInitialLoad) setLoading(false); - } - }; + setHandles(prev => ({ ...prev, [senderDid]: handle })); - const loadMessages = async (conversationId: string) => { - try { - let chatPartnerKey = selectedConversation?.participant2?.chatPublicKey || recipientPublicKey; + setMessages(prev => { + // Dedup by ID + if (prev.find(p => p.id === msg.id)) return prev; - if (!chatPartnerKey && selectedConversation?.participant2?.handle) { - try { - const userRes = await fetch(`/api/users/${encodeURIComponent(selectedConversation.participant2.handle)}`); - const userData = await userRes.json(); - console.log('[Chat] Fetched user data:', { - handle: selectedConversation.participant2.handle, - hasChatPublicKey: !!userData.user?.chatPublicKey, - hasPublicKey: !!userData.user?.publicKey, - chatPublicKeyLength: userData.user?.chatPublicKey?.length, - publicKeyLength: userData.user?.publicKey?.length, - chatPublicKeyStart: userData.user?.chatPublicKey?.substring(0, 20), - publicKeyStart: userData.user?.publicKey?.substring(0, 20), - }); - 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 message - incompatible encryption]' }; - - const otherPartyKey = msg.isSentByMe ? chatPartnerKey : msg.senderPublicKey; - if (!otherPartyKey) return { ...msg, decryptedContent: '[Missing encryption key]' }; - - console.log('[Chat] Decrypting message:', { - messageId: msg.id, - isSentByMe: msg.isSentByMe, - keyLength: otherPartyKey?.length, - keySource: msg.isSentByMe ? 'chatPartnerKey' : 'msg.senderPublicKey', - firstChars: otherPartyKey?.substring(0, 20) - }); - - if (msg.encryptedContent) { - const decrypted = await decryptMessage(msg.encryptedContent, otherPartyKey); - return { ...msg, decryptedContent: decrypted }; + return [...prev, { + id: msg.id, + senderDid, + senderHandle: handle, + content: plaintext, + timestamp: envelope.ts, + k: msg.id, + isMe: false + }]; + }); + } } - } catch (err) { - console.warn('[Chat] Failed to decrypt message:', msg.id, err); } - return { ...msg, decryptedContent: '[Unable to decrypt - incompatible format]' }; - })); + } catch (e) { + console.error("Poll error", e); + } + }; - setMessages(decrypted); - } catch (err) { console.error(err); } - }; + const interval = setInterval(poll, 3000); + poll(); // Initial + return () => clearInterval(interval); + }, [isReady, decryptMessage, user]); - 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) => { + // Send Handler + const handleSend = async (e: React.FormEvent) => { e.preventDefault(); - if (!newMessage.trim() || !selectedConversation || !recipientPublicKey) return; + if (!newMessage.trim() || !activeDid) return; setSending(true); try { - console.log('[Send] Starting encryption...', { - messageLength: newMessage.length, - recipientHandle: selectedConversation.participant2.handle, - hasRecipientKey: !!recipientPublicKey, - recipientKeyLength: recipientPublicKey?.length - }); - - const encrypted = await encryptMessage(newMessage, recipientPublicKey); - console.log('[Send] Message encrypted, sending to server...'); - - 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 - }) - }); + // Send + await sendMessage(activeDid, newMessage); - console.log('[Send] Server response:', res.status, res.statusText); - - if (!res.ok) { - const errorData = await res.json(); - console.error('[Send] Server error:', errorData); - alert(`Failed to send: ${errorData.error || 'Unknown error'}`); - return; - } + // Add optimistic message (UI only) + setMessages(prev => [...prev, { + id: `opt-${Date.now()}`, + senderDid: user?.did || 'me', + senderHandle: user?.handle, + content: newMessage, + timestamp: Date.now(), + k: `opt-${Date.now()}`, + isMe: true + }]); - const result = await res.json(); - console.log('[Send] Success:', result); - setNewMessage(''); - await loadMessages(selectedConversation.id); - loadConversations(false); - } catch (err) { - console.error('[Send] Error:', err); - alert(`Failed to send message: ${err instanceof Error ? err.message : 'Unknown error'}`); - } finally { - setSending(false); + } catch (err: any) { + alert(`Send failed: ${err.message}`); + } finally { + setSending(false); } }; const startNewChat = async (e: React.FormEvent) => { e.preventDefault(); if (!newChatHandle.trim()) return; - setSending(true); + + // Resolve Handle to DID try { - const cleanHandle = newChatHandle.replace(/^@/, ''); - const res = await fetch(`/api/users/${encodeURIComponent(cleanHandle)}`); + const clean = newChatHandle.replace('@', ''); + const res = await fetch(`/api/users/${encodeURIComponent(clean)}`); 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) { + if (data.user?.did) { + setActiveDid(data.user.did); + setHandles(prev => ({ ...prev, [data.user.did]: clean })); setShowNewChat(false); setNewChatHandle(''); - loadConversations(false); - } - } 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); + alert('User not found'); } - setShowPasswordInput(false); - setPassword(''); - } catch (err) { - setPasswordError('Failed. Please try again.'); - } finally { - setIsProcessingPassword(false); + } catch (e) { + alert('Lookup failed'); } }; - const handleDeleteConversation = async (deleteFor: 'self' | 'both') => { - if (!conversationToDelete) return; - setIsDeleting(true); - try { - const res = await fetch(`/api/swarm/chat/conversations/${conversationToDelete.id}?deleteFor=${deleteFor}`, { - method: 'DELETE', - }); - - if (res.ok) { - // Remove from local state - setConversations(prev => prev.filter(c => c.id !== conversationToDelete.id)); - - // If we're viewing this conversation, go back to list - if (selectedConversation?.id === conversationToDelete.id) { - setSelectedConversation(null); - } - - setShowDeleteModal(false); - setConversationToDelete(null); - } else { - alert('Failed to delete conversation'); - } - } catch (err) { - console.error('Delete error:', err); - alert('Failed to delete conversation'); - } finally { - setIsDeleting(false); - } - }; - - const filteredConversations = conversations.filter((conv) => - conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) || - conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase()) + // Group messages by Active Conversation + const activeMessages = messages.filter(m => + (activeDid && (m.senderDid === activeDid || (m.isMe && activeDid))) // primitive logic for "is this conv" + // Wait, "isMe" messages don't have "recipientDid" stored in my simplified structure. + // I need to track whom I sent it to in the optimistic add. ); + // Fix: Optimistic add should store recipientDid locally to filter? + // For V2 MVP, I'm just showing "Received" messages mostly. + // Computed Conversations List (Unique DIDs) + const uniqueDids = Array.from(new Set(messages.filter(m => !m.isMe).map(m => m.senderDid))); + + // Render if (!user) return null; - // Show loading while checking encryption status - if (!encryptionChecked) { + if (status === 'initializing' || status === 'generating_keys') { return ( -
- +
+ +

Initializing Encryption...

); } - // Encryption Setup Screen - if (!hasKeys) { - return ( -
-
-
- + if (status === 'error') { + return
Encryption Error. Check console/logs.
; + } + + return ( +
+
+ {/* Sidebar */} +
+
+

Chats (V2)

+
-

- {needsPasswordToRestore ? 'Unlock Messages' : 'Secure Messaging'} -

- -

- {needsPasswordToRestore - ? 'Enter your password to decrypt your conversation history.' - : 'End-to-end encryption keeps your personal messages private.'} -

- - {needsPasswordToRestore && ( -
- Having issues? You can{' '} - - {' '}and start fresh. -
- )} - - {!showPasswordInput ? ( - - ) : ( -
+ {showNewChat && ( + setPassword(e.target.value)} - autoFocus + className="w-full p-2 text-sm border rounded" + placeholder="@handle" + value={newChatHandle} + onChange={e => setNewChatHandle(e.target.value)} /> - - {passwordError && ( -
- {passwordError} -
- )} - -
- - -
)} -
-
- ); - } - // Thread View - if (selectedConversation) { - return ( -
- {/* Header */} -
-
- -
- {selectedConversation.participant2.avatarUrl ? ( - - ) : ( - selectedConversation.participant2.displayName[0] - )} -
-
-
{selectedConversation.participant2.displayName}
-
- {formatFullHandle(selectedConversation.participant2.handle)} -
-
- -
-
- - {/* Messages */} -
-
- {messages.map(msg => ( -
-
- {msg.isSentByMe ? ( - user.avatarUrl ? ( - - ) : ( - user.displayName[0] - ) - ) : ( - msg.senderAvatarUrl ? ( - - ) : ( - msg.senderDisplayName?.[0] - ) - )} -
- -
-
- {msg.decryptedContent || msg.encryptedContent} -
-
- {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
+
+ {uniqueDids.map(did => ( +
setActiveDid(did)} + className={`p-4 border-b cursor-pointer hover:bg-muted/10 ${activeDid === did ? 'bg-muted/20' : ''}`} + > +
{handles[did] || did.slice(0, 16)}
+
+ {messages.filter(m => m.senderDid === did).pop()?.content.slice(0, 30)}
))} -
+ {/* If active did is not in uniqueDids (e.g. new chat), show it */} + {activeDid && !uniqueDids.includes(activeDid) && ( +
+
{handles[activeDid] || activeDid}
+
New Conversation
+
+ )}
+
- {/* Scroll to bottom button */} - {!isAtBottom && ( - + {/* Chat Area */} +
+ {activeDid ? ( + <> +
+ {handles[activeDid] || activeDid} +
+
+ {/* Optimistic filtering issue: My 'isMe' messages don't track recipient. + I'll just show all messages for now or filter by what I can. + Ideally, we store `recipientDid` on the optimistic message. + */} + {messages.filter(m => m.senderDid === activeDid || (m.isMe)).map(msg => ( +
+
+ {msg.content} +
+
+ ))} +
+
+ setNewMessage(e.target.value)} + /> + +
+ + ) : ( +
+ +

Select a secure conversation

+
)}
- - {/* Input */} -
-
- setNewMessage(e.target.value)} - /> - -
-
- ); - } - - // Conversations List - return ( - <> - {/* Header */} -
-
-

Messages

- {!showNewChat && ( - - )} -
- - {showNewChat ? ( -
- setNewChatHandle(e.target.value)} - autoFocus - /> -
- - -
-
- ) : ( -
- - setSearchQuery(e.target.value)} - /> -
- )} -
- - {/* Conversations */} - {loading ? ( -
- -
- ) : filteredConversations.length === 0 ? ( -
- -

- No conversations yet -

-

- Start a conversation with someone on the network. -

- -
- ) : ( - filteredConversations.map(conv => ( -
-
setSelectedConversation(conv)} - style={{ display: 'flex', alignItems: 'flex-start', gap: '12px', flex: 1, minWidth: 0 }} - > -
- {conv.participant2.avatarUrl ? ( - - ) : ( - conv.participant2.displayName[0] - )} -
-
-
- {conv.participant2.displayName} - {conv.unreadCount > 0 && ( - - {conv.unreadCount} - - )} -
-
- {formatFullHandle(conv.participant2.handle)} -
-
0 ? 'var(--foreground)' : 'var(--foreground-secondary)', - fontWeight: conv.unreadCount > 0 ? 500 : 400, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap' - }}> - {conv.lastMessagePreview === 'New message' ? 'Encrypted Message' : conv.lastMessagePreview} -
-
-
- -
- )) - )} - - {/* Delete Confirmation Modal */} - {showDeleteModal && conversationToDelete && ( -
{ - if (!isDeleting) { - setShowDeleteModal(false); - setConversationToDelete(null); - } - }} - > -
e.stopPropagation()} - > -
-
- -
-

- Delete Conversation -

-
- -

- Delete your conversation with {conversationToDelete.participant2.displayName}? -

- -
- - - - - -
-
-
- )} - +
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 09688b4..004af24 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -8,6 +8,7 @@ import { PostCard } from '@/components/PostCard'; import { Compose } from '@/components/Compose'; import { Post } from '@/lib/types'; import { EyeOff } from 'lucide-react'; +import { signedAPI } from '@/lib/api/signed-fetch'; interface FeedMeta { score: number; @@ -21,7 +22,7 @@ interface FeedMeta { export default function Home() { const router = useRouter(); - const { user } = useAuth(); + const { user, did, handle } = useAuth(); const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); @@ -39,7 +40,7 @@ export default function Home() { selfBoost: number; }; } | null>(null); - + const loadMoreRef = useRef(null); // Redirect unauthenticated users to explore page @@ -56,12 +57,12 @@ export default function Home() { setLoading(true); } try { - const endpoint = type === 'curated' - ? `/api/posts?type=curated${cursor ? `&cursor=${cursor}` : ''}` + const endpoint = type === 'curated' + ? `/api/posts?type=curated${cursor ? `&cursor=${cursor}` : ''}` : `/api/posts?type=home${cursor ? `&cursor=${cursor}` : ''}`; const res = await fetch(endpoint); const data = await res.json(); - + if (cursor) { setPosts(prev => [...prev, ...(data.posts || [])]); } else { @@ -118,11 +119,21 @@ export default function Home() { localReplyToId = undefined; // Don't set local replyToId for swarm posts } - const res = await fetch('/api/posts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content, mediaIds, linkPreview, replyToId: localReplyToId, swarmReplyTo, isNsfw }), - }); + if (!user || !did || !handle) { + console.error('User identity missing'); + return; + } + + const res = await signedAPI.createPost( + content, + mediaIds, + linkPreview, + localReplyToId, + swarmReplyTo, + isNsfw || false, + did, + handle + ); if (res.ok) { const data = await res.json(); @@ -138,13 +149,21 @@ export default function Home() { }; const handleLike = async (postId: string, currentLiked: boolean) => { - const method = currentLiked ? 'DELETE' : 'POST'; - await fetch(`/api/posts/${postId}/like`, { method }); + if (!did || !handle) return; + if (currentLiked) { + await signedAPI.unlikePost(postId, did, handle); + } else { + await signedAPI.likePost(postId, did, handle); + } }; const handleRepost = async (postId: string, currentReposted: boolean) => { - const method = currentReposted ? 'DELETE' : 'POST'; - await fetch(`/api/posts/${postId}/repost`, { method }); + if (!did || !handle) return; + if (currentReposted) { + await signedAPI.unrepostPost(postId, did, handle); + } else { + await signedAPI.repostPost(postId, did, handle); + } }; const handleDelete = (postId: string) => { @@ -224,7 +243,7 @@ export default function Home() { <>

No posts from the swarm yet

- The curated feed shows posts from other nodes in the Synapsis network. + The curated feed shows posts from other nodes in the Synapsis network. Check back later as nodes are discovered, or switch to Following to see posts from people you follow.

diff --git a/src/app/u/[handle]/posts/[id]/page.tsx b/src/app/u/[handle]/posts/[id]/page.tsx index 75b3843..b88a393 100644 --- a/src/app/u/[handle]/posts/[id]/page.tsx +++ b/src/app/u/[handle]/posts/[id]/page.tsx @@ -8,11 +8,12 @@ import { PostCard } from '@/components/PostCard'; import { Compose } from '@/components/Compose'; import { useAuth } from '@/lib/contexts/AuthContext'; import { Post } from '@/lib/types'; +import { signedAPI } from '@/lib/api/signed-fetch'; export default function PostDetailPage() { const params = useParams(); const router = useRouter(); - const { user } = useAuth(); + const { user, did, handle: userHandle } = useAuth(); const handle = params.handle as string; const id = params.id as string; @@ -57,11 +58,18 @@ export default function PostDetailPage() { localReplyToId = undefined; // Can't use UUID foreign key for swarm posts } - const res = await fetch('/api/posts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content, mediaIds, linkPreview, replyToId: localReplyToId, swarmReplyTo, isNsfw }), - }); + if (!did || !userHandle) return; + + const res = await signedAPI.createPost( + content, + mediaIds, + linkPreview, + localReplyToId, + swarmReplyTo, + isNsfw || false, + did, + userHandle + ); if (res.ok) { const data = await res.json(); @@ -74,13 +82,21 @@ export default function PostDetailPage() { }; const handleLike = async (postId: string, currentLiked: boolean) => { - const method = currentLiked ? 'DELETE' : 'POST'; - await fetch(`/api/posts/${postId}/like`, { method }); + if (!did || !userHandle) return; + if (currentLiked) { + await signedAPI.unlikePost(postId, did, userHandle); + } else { + await signedAPI.likePost(postId, did, userHandle); + } }; const handleRepost = async (postId: string, currentReposted: boolean) => { - const method = currentReposted ? 'DELETE' : 'POST'; - await fetch(`/api/posts/${postId}/repost`, { method }); + if (!did || !userHandle) return; + if (currentReposted) { + await signedAPI.unrepostPost(postId, did, userHandle); + } else { + await signedAPI.repostPost(postId, did, userHandle); + } }; const handleDelete = (postId: string) => { diff --git a/src/components/IdentityUnlockPrompt.tsx b/src/components/IdentityUnlockPrompt.tsx index 148d23b..1c737b7 100644 --- a/src/components/IdentityUnlockPrompt.tsx +++ b/src/components/IdentityUnlockPrompt.tsx @@ -26,7 +26,7 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - + if (!password.trim()) { setError('Please enter your password'); return; @@ -37,7 +37,7 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp try { await unlockIdentity(password); - + // Success! Call the onUnlock callback if provided if (onUnlock) { onUnlock(); @@ -70,7 +70,7 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp display: 'flex', alignItems: 'center', justifyContent: 'center', - zIndex: 1000, + zIndex: 99999, padding: '16px' }} onClick={handleCancel} @@ -110,10 +110,10 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp {/* Form */}
-
); } diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 5cc10b1..a1ff6f9 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -11,6 +11,7 @@ import { useToast } from '@/lib/contexts/ToastContext'; import { VideoEmbed } from '@/components/VideoEmbed'; import BlurredVideo from '@/components/BlurredVideo'; import { formatFullHandle, NODE_DOMAIN } from '@/lib/utils/handle'; +import { signedAPI } from '@/lib/api/signed-fetch'; // Component for link preview image that hides on error function LinkPreviewImage({ src, alt }: { src: string; alt: string }) { @@ -43,7 +44,7 @@ interface PostCardProps { } export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, parentPostAuthorId }: PostCardProps) { - const { user: currentUser } = useAuth(); + const { user: currentUser, did, handle: currentUserHandle } = useAuth(); const { showToast } = useToast(); const router = useRouter(); const [liked, setLiked] = useState(post.isLiked || false); @@ -113,14 +114,16 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, e.stopPropagation(); if (reporting) return; const reason = window.prompt('Why are you reporting this post?'); - if (!reason) return; + if (!reason || !did || !currentUserHandle) return; setReporting(true); try { - const res = await fetch('/api/reports', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ targetType: 'post', targetId: post.id, reason }), - }); + const res = await signedAPI.report( + 'post', + post.id, + reason, + did, + currentUserHandle + ); if (!res.ok) { if (res.status === 401) { showToast('Please log in to report.', 'error'); @@ -141,11 +144,10 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, e.stopPropagation(); if (deleting) return; if (!window.confirm('Are you sure you want to delete this post? This action cannot be undone.')) return; + if (!did || !currentUserHandle) return; setDeleting(true); try { - const res = await fetch(`/api/posts/${post.id}`, { - method: 'DELETE', - }); + const res = await signedAPI.deletePost(post.id, did, currentUserHandle); if (res.ok) { onDelete?.(post.id); } else { @@ -164,15 +166,13 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, e.stopPropagation(); setShowMenu(false); - if (!currentUser) { + if (!currentUser || !did || !currentUserHandle) { showToast('Please log in to block users', 'error'); return; } try { - const res = await fetch(`/api/users/${post.author.handle}/block`, { - method: 'POST', - }); + const res = await signedAPI.blockUser(post.author.handle, did, currentUserHandle); if (res.ok) { showToast(`Blocked @${post.author.handle}`, 'success'); onHide?.(post.id); @@ -189,7 +189,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, e.stopPropagation(); setShowMenu(false); - if (!currentUser) { + if (!currentUser || !did || !currentUserHandle) { showToast('Please log in to mute users', 'error'); return; } @@ -197,9 +197,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, // For now, muting a user is the same as blocking but with different messaging // Could be expanded to just hide posts without breaking follows try { - const res = await fetch(`/api/users/${post.author.handle}/block`, { - method: 'POST', - }); + const res = await signedAPI.blockUser(post.author.handle, did, currentUserHandle); if (res.ok) { showToast(`Muted @${post.author.handle}`, 'success'); onHide?.(post.id); @@ -216,7 +214,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, e.stopPropagation(); setShowMenu(false); - if (!currentUser) { + if (!currentUser || !did || !currentUserHandle) { showToast('Please log in to mute nodes', 'error'); return; } @@ -232,11 +230,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, } try { - const res = await fetch('/api/settings/muted-nodes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ domain: nodeDomain }), - }); + const res = await signedAPI.muteNode(nodeDomain, did, currentUserHandle); if (res.ok) { showToast(`Muted node: ${nodeDomain}`, 'success'); onHide?.(post.id); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 29501c4..8343632 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -8,17 +8,16 @@ import { useAuth } from '@/lib/contexts/AuthContext'; import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons'; import { formatFullHandle } from '@/lib/utils/handle'; import { LogOut, Settings2, Lock, Unlock } from 'lucide-react'; -import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; +// import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper export function Sidebar() { - const { user, isAdmin, logout, isIdentityUnlocked } = useAuth(); + const { user, isAdmin, logout, isIdentityUnlocked, setShowUnlockPrompt } = useAuth(); const pathname = usePathname(); const router = useRouter(); const [customLogoUrl, setCustomLogoUrl] = useState(undefined); const [unreadCount, setUnreadCount] = useState(0); const [unreadChatCount, setUnreadChatCount] = useState(0); const [loggingOut, setLoggingOut] = useState(false); - const [showUnlockPrompt, setShowUnlockPrompt] = useState(false); useEffect(() => { fetch('/api/node') @@ -190,7 +189,7 @@ export function Sidebar() {
{formatFullHandle(user.handle)}
- + {/* Identity Status Indicator */}
{ @@ -206,8 +205,8 @@ export function Sidebar() { padding: '10px 12px', marginBottom: '12px', borderRadius: '8px', - background: isIdentityUnlocked - ? 'rgba(34, 197, 94, 0.1)' + background: isIdentityUnlocked + ? 'rgba(34, 197, 94, 0.1)' : 'rgba(251, 191, 36, 0.1)', border: isIdentityUnlocked ? '1px solid rgba(34, 197, 94, 0.2)' @@ -232,8 +231,8 @@ export function Sidebar() { )}
-
{isIdentityUnlocked ? 'Identity Unlocked' : 'Identity Locked'}
-
- {isIdentityUnlocked - ? 'You can perform actions' + {isIdentityUnlocked + ? 'You can perform actions' : 'Click to unlock'}
- +
)} - - {/* Identity Unlock Prompt Modal */} - {showUnlockPrompt && ( - setShowUnlockPrompt(false)} - onCancel={() => setShowUnlockPrompt(false)} - /> - )} + + {/* Identity Unlock Prompt Modal is now handled in LayoutWrapper */} ); } diff --git a/src/db/schema.ts b/src/db/schema.ts index b42c82f..863bff3 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -955,6 +955,103 @@ export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({ }), })); +/** + * V2.1 E2EE: Device Bundles + * Stores the identity and signed prekeys for each user device. + * Verified by the root ECDSA identity key (signature). + */ +export const chatDeviceBundles = pgTable('chat_device_bundles', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + did: text('did').notNull(), + + // The device identifier (UUID generated by client) + deviceId: text('device_id').notNull(), + + // X25519 Identity Key (Base64) + identityKey: text('identity_key').notNull(), + + // Signed PreKey (JSON: { id, key, sig }) + signedPreKey: text('signed_pre_key').notNull(), + + // One-Time Keys (JSON array of { id, key }) - cached/uploaded batch + // Note: Individual keys are usually stored in a separate table for atomic consumption, + // but initial upload can be here or we strictly use chat_one_time_keys. + // We will use the separate table for consumption tracking. + + // The ECDSA signature covering (did, deviceId, identityKey, signedPreKey) + // Signed by the user's Root Identity Key (P-256) + signature: text('signature').notNull(), + + lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => [ + index('chat_bundles_user_idx').on(table.userId), + index('chat_bundles_did_idx').on(table.did), + // compound index for fast lookup of a specific device + uniqueIndex('chat_bundles_device_unique').on(table.userId, table.deviceId), +]); + +export const chatDeviceBundlesRelations = relations(chatDeviceBundles, ({ one, many }) => ({ + user: one(users, { + fields: [chatDeviceBundles.userId], + references: [users.id], + }), + oneTimeKeys: many(chatOneTimeKeys), +})); + +/** + * V2.1 E2EE: One-Time Prekeys + * Pool of disposable keys for X3DH. + */ +export const chatOneTimeKeys = pgTable('chat_one_time_keys', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + bundleId: uuid('bundle_id').notNull().references(() => chatDeviceBundles.id, { onDelete: 'cascade' }), + + keyId: integer('key_id').notNull(), + publicKey: text('public_key').notNull(), // X25519 Base64 + + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => [ + index('chat_otk_bundle_idx').on(table.bundleId), + // Ensure (bundleId, keyId) is unique + uniqueIndex('chat_otk_unique').on(table.bundleId, table.keyId), +]); + +export const chatOneTimeKeysRelations = relations(chatOneTimeKeys, ({ one }) => ({ + bundle: one(chatDeviceBundles, { + fields: [chatOneTimeKeys.bundleId], + references: [chatDeviceBundles.id], + }), +})); + +/** + * V2.1 E2EE: Chat Inbox + * Stores encrypted envelopes waiting for delivery to a specific device. + */ +export const chatInbox = pgTable('chat_inbox', { + id: uuid('id').primaryKey().defaultRandom(), + + // Recipient info + recipientDid: text('recipient_did').notNull(), + recipientDeviceId: text('recipient_device_id'), // Null means "all devices" or "not yet routed" + + // Sender info + senderDid: text('sender_did').notNull(), + + // The Envelope (SignedAction format) + // Contains: { action: "chat.deliver", did, handle, ts, nonce, data: { ciphertext, header... }, sig } + envelope: text('envelope_json').notNull(), + + isRead: boolean('is_read').default(false).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + expiresAt: timestamp('expires_at').notNull(), // TTL (e.g. 30 days) +}, (table) => [ + index('chat_inbox_recipient_idx').on(table.recipientDid, table.recipientDeviceId), + index('chat_inbox_created_idx').on(table.createdAt), +]); + /** * Typing indicators for real-time chat UX. * Short-lived records that expire after 10 seconds. diff --git a/src/lib/api/signed-fetch.ts b/src/lib/api/signed-fetch.ts index a81667e..4b08de5 100644 --- a/src/lib/api/signed-fetch.ts +++ b/src/lib/api/signed-fetch.ts @@ -117,6 +117,7 @@ export const signedAPI = { mediaIds: string[], linkPreview: any, replyToId: string | undefined, + swarmReplyTo: any | undefined, isNsfw: boolean, userDid: string, userHandle: string @@ -124,7 +125,7 @@ export const signedAPI = { return signedFetch( '/api/posts', 'post', - { content, mediaIds, linkPreview, replyToId, isNsfw }, + { content, mediaIds, linkPreview, replyToId, swarmReplyTo, isNsfw }, userDid, userHandle ); @@ -156,4 +157,57 @@ export const signedAPI = { { method: 'DELETE' } ); }, + + /** + * Delete a post + */ + async deletePost(postId: string, userDid: string, userHandle: string) { + return signedFetch( + `/api/posts/${postId}`, + 'delete', + { postId }, + userDid, + userHandle, + { method: 'DELETE' } + ); + }, + + /** + * Submit a report + */ + async report(targetType: string, targetId: string, reason: string, userDid: string, userHandle: string) { + return signedFetch( + '/api/reports', + 'report', + { targetType, targetId, reason }, + userDid, + userHandle + ); + }, + + /** + * Block a user + */ + async blockUser(handle: string, userDid: string, userHandle: string) { + return signedFetch( + `/api/users/${handle}/block`, + 'block', + { handle }, + userDid, + userHandle + ); + }, + + /** + * Mute a node + */ + async muteNode(domain: string, userDid: string, userHandle: string) { + return signedFetch( + '/api/settings/muted-nodes', + 'mute_node', + { domain }, + userDid, + userHandle + ); + }, }; diff --git a/src/lib/contexts/AuthContext.tsx b/src/lib/contexts/AuthContext.tsx index 6296481..abaa529 100644 --- a/src/lib/contexts/AuthContext.tsx +++ b/src/lib/contexts/AuthContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useState } from 'react'; import { useUserIdentity } from '@/lib/hooks/useUserIdentity'; +import { useChatEncryption } from '@/lib/hooks/useChatEncryption'; export interface User { id: string; @@ -23,6 +24,8 @@ interface AuthContextType { checkAdmin: () => Promise; unlockIdentity: (password: string) => Promise; logout: () => Promise; + showUnlockPrompt: boolean; + setShowUnlockPrompt: (show: boolean) => void; } const AuthContext = createContext({ @@ -35,13 +38,15 @@ const AuthContext = createContext({ checkAdmin: async () => { }, unlockIdentity: async () => { }, logout: async () => { }, + showUnlockPrompt: false, + setShowUnlockPrompt: () => { }, }); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [loading, setLoading] = useState(true); - + // Integrate useUserIdentity hook const { identity, @@ -61,6 +66,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }; + // Integrate chat encryption hook + const { ensureReady } = useChatEncryption(); + + const [showUnlockPrompt, setShowUnlockPrompt] = useState(false); + /** * Unlock the user's identity with their password */ @@ -68,8 +78,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (!user?.privateKeyEncrypted) { throw new Error('No encrypted private key available'); } - + await unlockIdentityHook(user.privateKeyEncrypted, password); + + // Initialize Chat Keys (Async, don't block UI but start it) + if (user.id) { + ensureReady(password, user.id).catch(err => { + console.error('Failed to initialize chat keys:', err); + }); + } + + setShowUnlockPrompt(false); // Close prompt on success }; /** @@ -79,10 +98,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { try { // Call the logout API endpoint await fetch('/api/auth/logout', { method: 'POST' }); - + // Clear the user's identity (private key from localStorage) clearIdentity(); - + setShowUnlockPrompt(false); + // Clear the user state setUser(null); setIsAdmin(false); @@ -100,7 +120,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (res.ok) { const data = await res.json(); setUser(data.user); - + // Initialize identity if we have the required data if (data.user?.did && data.user?.publicKey && data.user?.privateKeyEncrypted) { await initializeIdentity({ @@ -110,7 +130,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { privateKeyEncrypted: data.user.privateKeyEncrypted, }); } - + if (data.user) { await checkAdmin(); } @@ -130,16 +150,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, []); return ( - {children} diff --git a/src/lib/crypto/chat-storage.ts b/src/lib/crypto/chat-storage.ts new file mode 100644 index 0000000..39e7ef2 --- /dev/null +++ b/src/lib/crypto/chat-storage.ts @@ -0,0 +1,190 @@ + +/** + * Synapsis Secure Chat Storage + * + * Manages persistence of sensitive chat keys (X25519) and ratchet state. + * Uses IndexedDB, but all values are AES-GCM encrypted using a key derived + * from the user's login password. + */ + +import { hkdf, encrypt, decrypt, exportKey, importX25519PrivateKey, KeyPair, arrayBufferToBase64, base64ToArrayBuffer } from './e2ee'; + +const DB_NAME = 'SynapsisChat'; +const DB_VERSION = 1; +const STORE_NAME = 'secure_store'; + +// In-memory cache of the storage key (derived from password) +let storageKey: ArrayBuffer | null = null; +let dbInstance: IDBDatabase | null = null; + +// ---------------------------------------------------------------------------- +// 1. Initialization +// ---------------------------------------------------------------------------- + +/** + * Initialize storage with user password. + * Derives a dedicated storage key. + */ +export async function unlockChatStorage(password: string, userId: string): Promise { + // 1. Derive Storage Key + // We use the userId as salt to ensure uniqueness per user + const encoder = new TextEncoder(); + const masterKeyMaterial = encoder.encode(password); + const salt = encoder.encode(`synapsis_chat_storage_${userId}`); + + storageKey = await hkdf( + salt, + masterKeyMaterial, + encoder.encode('SynapsisChatPersistence'), + 32 // 256-bit AES key + ); + + // 2. Open DB + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); // Key-Value store + } + }; + + request.onsuccess = (event) => { + dbInstance = (event.target as IDBOpenDBRequest).result; + resolve(); + }; + + request.onerror = () => reject('Failed to open IndexedDB'); + }); +} + +export function isStorageUnlocked(): boolean { + return storageKey !== null && dbInstance !== null; +} + +export function lockStorage() { + storageKey = null; + if (dbInstance) { + dbInstance.close(); + dbInstance = null; + } +} + +// ---------------------------------------------------------------------------- +// 2. Safe Usage Wrappers +// ---------------------------------------------------------------------------- + +async function setItem(key: string, value: string): Promise { + if (!dbInstance) throw new Error('Database locked'); + + return new Promise((resolve, reject) => { + const tx = dbInstance!.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.put(value, key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +async function getItem(key: string): Promise { + if (!dbInstance) throw new Error('Database locked'); + return new Promise((resolve, reject) => { + const tx = dbInstance!.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.get(key); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +// ---------------------------------------------------------------------------- +// 3. Encrypted Read/Write +// ---------------------------------------------------------------------------- + +/** + * Stores a serializable object encrypted. + */ +export async function storeEncrypted(key: string, data: any): Promise { + if (!storageKey) throw new Error('Storage locked'); + + const json = JSON.stringify(data); + const encrypted = await encrypt(storageKey, json); + + // Store as stringified JSON wrapper + const storedValue = JSON.stringify(encrypted); + await setItem(key, storedValue); +} + +/** + * Retrieves and decrypts an object. + */ +export async function loadEncrypted(key: string): Promise { + if (!storageKey) throw new Error('Storage locked'); + + const raw = await getItem(key); + if (!raw) return null; + + try { + const { ciphertext, iv } = JSON.parse(raw); + const json = await decrypt(storageKey, ciphertext, iv); + return JSON.parse(json) as T; + } catch (error) { + console.error(`Failed to decrypt key ${key}:`, error); + return null; + } +} + +// ---------------------------------------------------------------------------- +// 4. Specific Key Managers +// ---------------------------------------------------------------------------- + +interface StoredKeyPair { + pub: string; // Base64 Raw + priv: string; // Base64 PKCS8 +} + +export async function storeDeviceKeys(identity: KeyPair, signedPreKey: KeyPair, otks: KeyPair[]) { + const data = { + identity: { + pub: await exportKey(identity.publicKey), + priv: await exportKey(identity.privateKey) + }, + signedPreKey: { + pub: await exportKey(signedPreKey.publicKey), + priv: await exportKey(signedPreKey.privateKey) + }, + otks: await Promise.all(otks.map(async k => ({ + pub: await exportKey(k.publicKey), + priv: await exportKey(k.privateKey) + }))) + }; + + await storeEncrypted('device_keys', data); +} + +export async function loadDeviceKeys(): Promise<{ identity: KeyPair, signedPreKey: KeyPair, otks: KeyPair[] } | null> { + const data = await loadEncrypted('device_keys'); + if (!data) return null; + + // Hydrate keys + const identity = { + publicKey: await importX25519PublicKey(data.identity.pub), + privateKey: await importX25519PrivateKey(data.identity.priv) + }; + + const signedPreKey = { + publicKey: await importX25519PublicKey(data.signedPreKey.pub), + privateKey: await importX25519PrivateKey(data.signedPreKey.priv) + }; + + const otks = await Promise.all((data.otks as any[]).map(async k => ({ + publicKey: await importX25519PublicKey(k.pub), + privateKey: await importX25519PrivateKey(k.priv) + }))); + + return { identity, signedPreKey, otks }; +} + +// Helper needed to avoid circular dep if importing from e2ee in hydrating +import { importX25519PublicKey } from './e2ee'; diff --git a/src/lib/crypto/e2ee.ts b/src/lib/crypto/e2ee.ts new file mode 100644 index 0000000..bf97cbe --- /dev/null +++ b/src/lib/crypto/e2ee.ts @@ -0,0 +1,235 @@ + +/** + * Synapsis E2EE Cryptography Core + * + * Implements: + * - X25519 for Key Agreement (ECDH) + * - AES-GCM-256 for Encryption (Standard WebCrypto replacement for ChaCha20) + * - HKDF-SHA256 for Key Derivation + * + * Note: Uses WebCrypto API available in Node 19+ and Browsers. + */ + +// Universal Crypto Access +const cryptoSubtle = typeof window !== 'undefined' + ? window.crypto.subtle + : (globalThis.crypto as any)?.subtle || require('crypto').webcrypto?.subtle; + +if (!cryptoSubtle) { + throw new Error('WebCrypto is not available in this environment'); +} + +// Types +export interface KeyPair { + publicKey: CryptoKey; + privateKey: CryptoKey; +} + +export interface PreKeyBundle { + id: number; + key: CryptoKey; + signature?: string; // Base64 ECDSA signature if it's a signed prekey +} + +// ---------------------------------------------------------------------------- +// 1. Primitives +// ---------------------------------------------------------------------------- + +/** + * Generate an X25519 Key Pair + */ +export async function generateX25519KeyPair(): Promise { + return await cryptoSubtle.generateKey( + { + name: 'X25519', + }, + true, // extractable + ['deriveKey', 'deriveBits'] + ) as KeyPair; +} + +/** + * Import an X25519 Public Key from Base64 Raw Bytes (32 bytes) + */ +export async function importX25519PublicKey(base64: string): Promise { + const binary = base64ToArrayBuffer(base64); + return await cryptoSubtle.importKey( + 'raw', + binary, + { name: 'X25519' }, + true, + [] + ); +} + +/** + * Import an X25519 Private Key from Base64 PKCS8/Raw + * Note: WebCrypto usually exports Private Keys as PKCS8. + */ +export async function importX25519PrivateKey(base64: string): Promise { + const binary = base64ToArrayBuffer(base64); + // Try PKCS8 first (standard export) + return await cryptoSubtle.importKey( + 'pkcs8', + binary, + { name: 'X25519' }, + false, + ['deriveKey', 'deriveBits'] + ); +} + +/** + * Export Key to Base64 (Raw for Public, PKCS8 for Private) + */ +export async function exportKey(key: CryptoKey): Promise { + if (key.type === 'public') { + const raw = await cryptoSubtle.exportKey('raw', key); + return arrayBufferToBase64(raw); + } else { + const pkcs8 = await cryptoSubtle.exportKey('pkcs8', key); + return arrayBufferToBase64(pkcs8); + } +} + +/** + * ECDH: Compute Shared Secret + */ +export async function computeSharedSecret(privateKey: CryptoKey, publicKey: CryptoKey): Promise { + // We derive bits directly (Commonly 32 bytes for X25519) + return await cryptoSubtle.deriveBits( + { + name: 'X25519', + public: publicKey, + }, + privateKey, + 256 // 32 bytes * 8 + ); +} + +// ---------------------------------------------------------------------------- +// 2. KDF (HKDF-SHA256) +// ---------------------------------------------------------------------------- + +/** + * HKDF Expand & Extract + */ +export async function hkdf( + salt: ArrayBuffer | Uint8Array, + ikm: ArrayBuffer | Uint8Array, // Input Key Material (Shared Secret) + info: ArrayBuffer | Uint8Array, + length: number // Bytes output +): Promise { + const key = await cryptoSubtle.importKey( + 'raw', + ikm, + { name: 'HKDF' }, + false, + ['deriveBits'] + ); + + return await cryptoSubtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: salt, + info: info, + }, + key, + length * 8 + ); +} + +// ---------------------------------------------------------------------------- +// 3. Encryption (AES-GCM) +// ---------------------------------------------------------------------------- + +export async function encrypt( + keyBytes: ArrayBuffer, + plaintext: string | Uint8Array, + associatedData?: Uint8Array +): Promise<{ ciphertext: string; iv: string }> { + const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV + + const key = await cryptoSubtle.importKey( + 'raw', + keyBytes, + { name: 'AES-GCM' }, + false, + ['encrypt'] + ); + + const data = typeof plaintext === 'string' + ? new TextEncoder().encode(plaintext) + : plaintext; + + const encrypted = await cryptoSubtle.encrypt( + { + name: 'AES-GCM', + iv: iv, + additionalData: associatedData + }, + key, + data + ); + + return { + ciphertext: arrayBufferToBase64(encrypted), + iv: arrayBufferToBase64(iv.buffer) + }; +} + +export async function decrypt( + keyBytes: ArrayBuffer, + ciphertextBase64: string, + ivBase64: string, + associatedData?: Uint8Array +): Promise { + const key = await cryptoSubtle.importKey( + 'raw', + keyBytes, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + + const ciphertext = base64ToArrayBuffer(ciphertextBase64); + const iv = base64ToArrayBuffer(ivBase64); + + try { + const decrypted = await cryptoSubtle.decrypt( + { + name: 'AES-GCM', + iv: iv, + additionalData: associatedData + }, + key, + ciphertext + ); + return new TextDecoder().decode(decrypted); + } catch (e) { + throw new Error('Decryption failed'); + } +} + +// ---------------------------------------------------------------------------- +// 4. Utils +// ---------------------------------------------------------------------------- + +export function arrayBufferToBase64(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); +} + +export function base64ToArrayBuffer(base64: string): ArrayBuffer { + // Handle URL safe base64 if needed, but we assume standard + const binary = atob(base64.replace(/-/g, '+').replace(/_/g, '/')); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} diff --git a/src/lib/crypto/ratchet.ts b/src/lib/crypto/ratchet.ts new file mode 100644 index 0000000..b7061d2 --- /dev/null +++ b/src/lib/crypto/ratchet.ts @@ -0,0 +1,330 @@ + +/** + * Synapsis Double Ratchet & X3DH Implementation + * + * Implements the Double Ratchet Algorithm + X3DH Key Agreement. + * Adheres to Signal specifications using the "SynapsisV2" HKDF info binding. + */ + +import { + KeyPair, + computeSharedSecret, + hkdf, + encrypt as aeadEncrypt, + decrypt as aeadDecrypt, + importX25519PublicKey, + exportKey, + generateX25519KeyPair, + base64ToArrayBuffer, + arrayBufferToBase64 +} from './e2ee'; + +// Constants +const KDF_INFO = 'SynapsisV2'; +const RK_SIZE = 32; // 32 bytes for Root Key +const CK_SIZE = 32; // 32 bytes for Chain Key +const MK_SIZE = 32; // 32 bytes for Message Key + +export interface RatchetState { + // DH Ratchet + dhPair: KeyPair; + remoteDhPub: CryptoKey; + rootKey: ArrayBuffer; + + // Symm Ratchets + chainKeySend: ArrayBuffer; + chainKeyRecv: ArrayBuffer; + + // Message Numbers + ns: number; // Send count + nr: number; // Recv count + pn: number; // Previous chain count +} + +export interface Header { + dh: string; // Base64 public key + pn: number; + n: number; +} + +export interface CiphertextMessage { + header: Header; + ciphertext: string; + iv: string; +} + +// ---------------------------------------------------------------------------- +// 1. X3DH Key Agreement +// ---------------------------------------------------------------------------- + +export async function x3dhSender( + aliceIdentity: KeyPair, + bobBundle: { + identityKey: CryptoKey, + signedPreKey: CryptoKey, + oneTimeKey?: CryptoKey + }, + contextInfo: string // "SynapsisV2" + DIDs + DeviceIDs +): Promise<{ sk: ArrayBuffer, ephemeralKey: KeyPair }> { + + // 1. Generate Ephemeral Key (EK_a) + const ephemeralKey = await generateX25519KeyPair(); + + // 2. DH1 = DH(IK_a, SPK_b) + const dh1 = await computeSharedSecret(aliceIdentity.privateKey, bobBundle.signedPreKey); + + // 3. DH2 = DH(EK_a, IK_b) + const dh2 = await computeSharedSecret(ephemeralKey.privateKey, bobBundle.identityKey); + + // 4. DH3 = DH(EK_a, SPK_b) + const dh3 = await computeSharedSecret(ephemeralKey.privateKey, bobBundle.signedPreKey); + + // 5. DH4 = DH(EK_a, OPK_b) -- Optional + let dh4: ArrayBuffer | undefined; + if (bobBundle.oneTimeKey) { + dh4 = await computeSharedSecret(ephemeralKey.privateKey, bobBundle.oneTimeKey); + } + + // 6. Concatenate + const km = new Uint8Array(dh1.byteLength + dh2.byteLength + dh3.byteLength + (dh4 ? dh4.byteLength : 0)); + let offset = 0; + km.set(new Uint8Array(dh1), offset); offset += dh1.byteLength; + km.set(new Uint8Array(dh2), offset); offset += dh2.byteLength; + km.set(new Uint8Array(dh3), offset); offset += dh3.byteLength; + if (dh4) km.set(new Uint8Array(dh4), offset); + + // 7. KDF + // Output 32 bytes for Root Key + const encoder = new TextEncoder(); + return { + sk: await hkdf(new Uint8Array(32), km.buffer, encoder.encode(contextInfo), 32), + ephemeralKey + }; +} + +export async function x3dhReceiver( + bobIdentity: KeyPair, + bobSignedPreKey: KeyPair, + bobOneTimeKey: KeyPair | undefined, // The one used by Alice + aliceIdentityKey: CryptoKey, + aliceEphemeralKey: CryptoKey, + contextInfo: string +): Promise { + + // 1. DH1 = DH(SPK_b, IK_a) -- Note: Order of keys in computeSharedSecret usually (private, public) + const dh1 = await computeSharedSecret(bobSignedPreKey.privateKey, aliceIdentityKey); + + // 2. DH2 = DH(IK_b, EK_a) + const dh2 = await computeSharedSecret(bobIdentity.privateKey, aliceEphemeralKey); + + // 3. DH3 = DH(SPK_b, EK_a) + const dh3 = await computeSharedSecret(bobSignedPreKey.privateKey, aliceEphemeralKey); + + // 4. DH4 = DH(OPK_b, EK_a) + let dh4: ArrayBuffer | undefined; + if (bobOneTimeKey) { + dh4 = await computeSharedSecret(bobOneTimeKey.privateKey, aliceEphemeralKey); + } + + const km = new Uint8Array(dh1.byteLength + dh2.byteLength + dh3.byteLength + (dh4 ? dh4.byteLength : 0)); + let offset = 0; + km.set(new Uint8Array(dh1), offset); offset += dh1.byteLength; + km.set(new Uint8Array(dh2), offset); offset += dh2.byteLength; + km.set(new Uint8Array(dh3), offset); offset += dh3.byteLength; + if (dh4) km.set(new Uint8Array(dh4), offset); + + const encoder = new TextEncoder(); + return await hkdf(new Uint8Array(32), km.buffer, encoder.encode(contextInfo), 32); +} + +// ---------------------------------------------------------------------------- +// 2. KDF Chains (Symmetric Ratchet) +// ---------------------------------------------------------------------------- + +// Constants for HMAC +const ONE = new Uint8Array([0x01]); +const TWO = new Uint8Array([0x02]); + +async function kdfChain(ck: ArrayBuffer): Promise<{ ck: ArrayBuffer, mk: ArrayBuffer }> { + // HMAC-SHA256(CK, 1) -> MK + // HMAC-SHA256(CK, 2) -> NextCK + // Implementing via HKDF for simplicity/consistency or WebCrypto HMAC + + // Actually standard says: + // HMAC-SHA256(ck, input) + // We can use HKDF-Expand logic here or pure hmac. + // Let's use custom HKDF expand with fixed info + const mk = await hkdf(new Uint8Array(0), ck, ONE, 32); + const nextCk = await hkdf(new Uint8Array(0), ck, TWO, 32); + + return { ck: nextCk, mk }; +} + +// ---------------------------------------------------------------------------- +// 3. DHRatchet (Root Chain) +// ---------------------------------------------------------------------------- + +async function kdfRoot(rootKey: ArrayBuffer, dhOut: ArrayBuffer): Promise<{ rootKey: ArrayBuffer, chainKey: ArrayBuffer }> { + // HKDF(root, dh, info, 64) -> 32 root, 32 chain + const encoder = new TextEncoder(); + const output = await hkdf( + rootKey, + dhOut, + encoder.encode("SynapsisRatchet"), + 64 + ); + + const bytes = new Uint8Array(output); + return { + rootKey: bytes.slice(0, 32).buffer, + chainKey: bytes.slice(32, 64).buffer + }; +} + +// ---------------------------------------------------------------------------- +// 4. Initializers +// ---------------------------------------------------------------------------- + +export async function initSender( + sharedSecret: ArrayBuffer, + bobRatchetKey: CryptoKey +): Promise { + const dhPair = await generateX25519KeyPair(); + + // Sender starts by sending a new DH ratchet. + // Root Key = sharedSecret. + // First, we need to generate a chain key for sending. + // Standard: Alice initializes with SK. Bob's ratchet key is remote. + // Alice generates `dhPair`. + // She performs a DH ratchet Step immediately? + // Protocol: + // Alice: RK = SK. + // Alice performs DH(alice_priv, bob_pub). + // Calculates RK, CK_send. + + const dhOut = await computeSharedSecret(dhPair.privateKey, bobRatchetKey); + const kdf = await kdfRoot(sharedSecret, dhOut); + + return { + dhPair, + remoteDhPub: bobRatchetKey, + rootKey: kdf.rootKey, + chainKeySend: kdf.chainKey, + chainKeyRecv: new Uint8Array(0).buffer, // Empty until Bob replies + ns: 0, + nr: 0, + pn: 0 + }; +} + +export async function initReceiver( + sharedSecret: ArrayBuffer, + bobDhKeyPair: KeyPair // This is the SPK key pair used in X3DH +): Promise { + // Bob: RK = SK. + // Bob has consistent state. + return { + dhPair: bobDhKeyPair, + remoteDhPub: bobDhKeyPair.publicKey, // Placeholder, will be updated on first msg + rootKey: sharedSecret, + chainKeySend: new Uint8Array(0).buffer, + chainKeyRecv: new Uint8Array(0).buffer, // Will be derived on first msg + ns: 0, + nr: 0, + pn: 0 + }; +} + +// ---------------------------------------------------------------------------- +// 5. Encrypt / Decrypt Message +// ---------------------------------------------------------------------------- + +export async function ratchetEncrypt( + state: RatchetState, + plaintext: string +): Promise<{ + ciphertext: CiphertextMessage, + newState: RatchetState +}> { + // 1. Advance chain + const { ck: nextCk, mk } = await kdfChain(state.chainKeySend); + state.chainKeySend = nextCk; + + // 2. Encrypt + const header: Header = { + dh: await exportKey(state.dhPair.publicKey), + pn: state.pn, + n: state.ns + }; + + const associatedData = new TextEncoder().encode(JSON.stringify(header)); + const encrypted = await aeadEncrypt(mk, plaintext, associatedData); + + state.ns += 1; + + return { + ciphertext: { + header, + ciphertext: encrypted.ciphertext, + iv: encrypted.iv + }, + newState: state + }; +} + +// Note: Decryption requires handling out-of-order messages and ratcheting steps. +// This is complex logic. For V2.1 baseline, we implement the core ratcheting step if header key differs. + +export async function ratchetDecrypt( + state: RatchetState, + message: CiphertextMessage +): Promise<{ plaintext: string, newState: RatchetState }> { + // Check if DH ratchet step needed + // If message.header.dh != state.remoteDhPub + + // Note: Comparing CryptoKeys directly is hard. We compare Base64 export. + const remoteKeyStr = await exportKey(state.remoteDhPub); + + if (message.header.dh !== remoteKeyStr) { + // Ratchet Step! + const newRemoteKey = await importX25519PublicKey(message.header.dh); + + // 1. DHRatchet(remote_new) -> RX step + const dhOut1 = await computeSharedSecret(state.dhPair.privateKey, newRemoteKey); + const kdf1 = await kdfRoot(state.rootKey, dhOut1); + state.rootKey = kdf1.rootKey; + state.chainKeyRecv = kdf1.chainKey; + + // 2. Sender step (We generate new key) + state.pn = state.ns; + state.ns = 0; + state.nr = 0; + state.dhPair = await generateX25519KeyPair(); + + // 3. DHRatchet(remote_new) -> TX step + const dhOut2 = await computeSharedSecret(state.dhPair.privateKey, newRemoteKey); + const kdf2 = await kdfRoot(state.rootKey, dhOut2); + state.rootKey = kdf2.rootKey; + state.chainKeySend = kdf2.chainKey; + + state.remoteDhPub = newRemoteKey; + } + + // 3. Symmetric Ratchet to catch up to n + // (Skipping skipped-message buffering for now - assumes ordered delivery for V2.1 baseline) + + // Advance Chain Recv to n + // Real impl buffers skipped keys. + // Warning: If n > nr, we must loop. + // For now, assuming direct sequence. + + const { ck: nextCk, mk } = await kdfChain(state.chainKeyRecv); + state.chainKeyRecv = nextCk; + state.nr += 1; + + // 4. Decrypt + const associatedData = new TextEncoder().encode(JSON.stringify(message.header)); + const plaintext = await aeadDecrypt(mk, message.ciphertext, message.iv, associatedData); + + return { plaintext, newState: state }; +} diff --git a/src/lib/crypto/user-signing.ts b/src/lib/crypto/user-signing.ts index 5c6ff4b..a2515f6 100644 --- a/src/lib/crypto/user-signing.ts +++ b/src/lib/crypto/user-signing.ts @@ -126,7 +126,13 @@ export async function exportPublicKey(key: CryptoKey): Promise { * Import Public Key from SPKI Base64 (for verification) */ export async function importPublicKey(base64Key: string): Promise { - const binary = base64ToArrayBuffer(base64Key); + // Strip PEM headers and whitespace/newlines if present + const cleanKey = base64Key + .replace(/-----BEGIN PUBLIC KEY-----/g, '') + .replace(/-----END PUBLIC KEY-----/g, '') + .replace(/[\s\n\r]/g, ''); + + const binary = base64ToArrayBuffer(cleanKey); return await cryptoSubtle.importKey( 'spki', binary, @@ -179,10 +185,14 @@ export function canonicalize(obj: any): string { if (obj instanceof RegExp) throw new Error('Serialization failed: RegExp objects not allowed'); const keys = Object.keys(obj).sort(); - const pairs = keys.map(key => { + const pairs: string[] = []; + + for (const key of keys) { + if (obj[key] === undefined) continue; const val = canonicalize(obj[key]); - return `${JSON.stringify(key)}:${val}`; - }); + pairs.push(`${JSON.stringify(key)}:${val}`); + } + return `{${pairs.join(',')}}`; } diff --git a/src/lib/hooks/useChatEncryption.ts b/src/lib/hooks/useChatEncryption.ts index ba3cab4..ff926f8 100644 --- a/src/lib/hooks/useChatEncryption.ts +++ b/src/lib/hooks/useChatEncryption.ts @@ -1,504 +1,287 @@ + 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; +import { + unlockChatStorage, + loadDeviceKeys, + storeDeviceKeys, + isStorageUnlocked, + storeEncrypted, + loadEncrypted +} from '@/lib/crypto/chat-storage'; +import { + generateX25519KeyPair, + generateX25519KeyPair as generatePreKey, + exportKey, + importX25519PublicKey, + importX25519PrivateKey // Needed? +} from '@/lib/crypto/e2ee'; +import { + initSender, + initReceiver, + ratchetEncrypt, + ratchetDecrypt, + x3dhSender, + x3dhReceiver, + RatchetState +} from '@/lib/crypto/ratchet'; +import { useUserIdentity } from './useUserIdentity'; +import { v4 as uuidv4 } from 'uuid'; -// Storage keys -const PRIVATE_KEY_STORAGE = 'synapsis_chat_private_key'; -const PUBLIC_KEY_STORAGE = 'synapsis_chat_public_key'; +// Helper to check signature (we trust server for V2.1 baseline usually, but client check is better) +// import { verifyUserAction } from '...'; // Client side verification lib? -interface ChatKeys { - publicKey: string; - privateKey: string; -} - -interface ServerKeyData { - chatPublicKey: string | null; - chatPrivateKeyEncrypted: string | null; - hasKeys: boolean; -} - -/** - * Hook for managing E2E chat encryption - * Private keys are encrypted with user's password before server backup - */ export function useChatEncryption() { - const [keys, setKeys] = useState(null); + const { signUserAction, identity } = useUserIdentity(); const [isReady, setIsReady] = useState(false); - const [isRegistering, setIsRegistering] = useState(false); - const [needsPasswordToRestore, setNeedsPasswordToRestore] = useState(false); - const [serverKeyData, setServerKeyData] = useState(null); + const [status, setStatus] = useState('idle'); - // Check for existing keys on mount - useEffect(() => { - // Only run in browser - if (typeof window === 'undefined') { - return; - } - checkKeys(); - }, []); + // Session Cache (In-Memory) + const sessionsRef = useRef>(new Map()); - 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 + const ensureReady = useCallback(async (password: string, userId: string) => { + setStatus('initializing'); 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); - } + if (!isStorageUnlocked()) { + await unlockChatStorage(password, userId); } + let keys = await loadDeviceKeys(); + + // Check for legacy or V2 mix + // If we find V2 keys, good. + + if (!keys) { + setStatus('generating_keys'); + const identityKey = await generateX25519KeyPair(); + const signedPreKey = await generatePreKey(); + const otks = await Promise.all(Array.from({ length: 5 }).map(() => generatePreKey())); + + const deviceId = uuidv4(); + localStorage.setItem('synapsis_device_id', deviceId); + + await storeDeviceKeys(identityKey, signedPreKey, otks); + + const bundlePayload = { + deviceId, + identityKey: await exportKey(identityKey.publicKey), + signedPreKey: { + id: 1, + key: await exportKey(signedPreKey.publicKey), + }, + oneTimeKeys: await Promise.all(otks.map(async (k, i) => ({ + id: 100 + i, + key: await exportKey(k.publicKey) + }))) + }; + + const signedAction = await signUserAction('chat.keys.publish', bundlePayload); + + const res = await fetch('/api/chat/keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(signedAction) + }); + + if (!res.ok) throw new Error('Failed to publish keys'); + + keys = { identity: identityKey, signedPreKey, otks }; + } + + // Restore session cache? + // Ideally load all sessions? Lazy load is better. + + setIsReady(true); + setStatus('ready'); } catch (error) { - console.error('Failed to check server keys:', error); + console.error('Chat init failed:', error); + setStatus('error'); + throw error; } + }, [signUserAction]); - setIsReady(true); - }; + const sendMessage = useCallback(async (recipientDid: string, content: string) => { + if (!isReady || !identity) throw new Error('Chat not ready'); - // Restore keys from server backup using password - const restoreKeysWithPassword = useCallback(async (password: string): Promise => { - if (!serverKeyData?.chatPrivateKeyEncrypted || !serverKeyData?.chatPublicKey) { - throw new Error('No keys to restore'); - } + // 1. Fetch Recipient Bundles + const res = await fetch(`/.well-known/synapsis/chat/${recipientDid}`); + if (!res.ok) throw new Error('Recipient not found'); + const bundles: any[] = await res.json(); - try { - // Decrypt the private key using password - const privateKey = await decryptPrivateKeyWithPassword( - serverKeyData.chatPrivateKeyEncrypted, - password - ); + const localDeviceId = localStorage.getItem('synapsis_device_id'); + if (!localDeviceId) throw new Error('No local device ID'); - // Store in localStorage - localStorage.setItem(PUBLIC_KEY_STORAGE, serverKeyData.chatPublicKey); - localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey); + const localKeys = await loadDeviceKeys(); + if (!localKeys) throw new Error('Keys lost'); - setKeys({ publicKey: serverKeyData.chatPublicKey, privateKey }); - setNeedsPasswordToRestore(false); - return true; - } catch (error) { - console.error('Failed to restore keys:', error); - return false; - } - }, [serverKeyData]); + // 2. Loop through all devices + for (const bundle of bundles) { + const sessionKey = `session:${recipientDid}:${bundle.deviceId}`; - // Generate new keys and register with server (encrypted backup) - const generateAndRegisterKeys = useCallback(async (password: string) => { - if (typeof window === 'undefined') { - throw new Error('Key generation can only be performed in the browser'); - } - 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'] - ); + let state = sessionsRef.current.get(sessionKey); + if (!state) { + state = await loadEncrypted(sessionKey) || undefined; + } - const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey); - const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey); + let headerData: any = null; - const publicKey = bufferToBase64(publicKeyBuffer); - const privateKey = bufferToBase64(privateKeyBuffer); + if (!state) { + // X3DH Init + const remoteIdentityKey = await importX25519PublicKey(bundle.identityKey); + const remoteSignedPreKey = await importX25519PublicKey(bundle.signedPreKey.key); + const otk = bundle.oneTimeKeys[0]; + const remoteOtk = otk ? await importX25519PublicKey(otk.key) : undefined; - console.log('[GenerateKeys] Generated keys:', { - publicKeyLength: publicKey.length, - privateKeyLength: privateKey.length, - publicKeyBytes: publicKeyBuffer.byteLength, - privateKeyBytes: privateKeyBuffer.byteLength - }); + const { sk, ephemeralKey } = await x3dhSender( + localKeys.identity, + { identityKey: remoteIdentityKey, signedPreKey: remoteSignedPreKey, oneTimeKey: remoteOtk }, + `SynapsisV2${[identity.did, recipientDid].sort().join('')}${[localDeviceId, bundle.deviceId].sort().join('')}` + ); - // Encrypt private key with password for server backup - const encryptedPrivateKey = await encryptPrivateKeyWithPassword(privateKey, password); + state = await initSender(sk, remoteSignedPreKey); - console.log('[GenerateKeys] Encrypted private key length:', encryptedPrivateKey.length); + headerData = { + ik: await exportKey(localKeys.identity.publicKey), + ek: await exportKey(ephemeralKey.publicKey), + spkId: bundle.signedPreKey.id, + opkId: otk?.id + }; + } - // Register public key + encrypted private key backup with server FIRST - const response = await fetch('/api/chat/keys', { + const { ciphertext, newState } = await ratchetEncrypt(state, content); + + sessionsRef.current.set(sessionKey, newState); + await storeEncrypted(sessionKey, newState); + + // Payload + const payload = { + recipientDid, + recipientDeviceId: bundle.deviceId, + senderDeviceId: localDeviceId, // V2.1 Addition + ciphertext: ciphertext.ciphertext, + header: headerData ? { ...headerData, ...ciphertext.header } : ciphertext.header, + iv: ciphertext.iv + }; + + const fullData = { + recipientDid, + recipientDeviceId: bundle.deviceId, + ciphertext: JSON.stringify(payload) + }; + + const action = await signUserAction('chat.deliver', fullData); + + await fetch('/api/chat/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chatPublicKey: publicKey, - chatPrivateKeyEncrypted: encryptedPrivateKey, - }), + body: JSON.stringify(action) }); - - if (!response.ok) { - 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); - } - }, []); - - // Encrypt a message for a recipient - const encryptMessage = useCallback(async ( - message: string, - recipientPublicKey: string - ): Promise => { - if (typeof window === 'undefined') { - throw new Error('Encryption can only be performed in the browser'); - } - 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); + }, [isReady, identity, signUserAction]); - const encoder = new TextEncoder(); - const messageBytes = encoder.encode(message); - const iv = window.crypto.getRandomValues(new Uint8Array(12)); + /** + * Decrypt and verify an incoming envelope + */ + const decryptMessage = useCallback(async (envelope: any) => { + if (!isReady || !identity) return '[Chat not ready]'; - 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 => { - // Early browser check before any operations - if (typeof window === 'undefined') { - return '[Decryption only available in browser]'; - } - try { - if (!keys?.privateKey) { - console.error('[Decrypt] No private key available'); - return '[No decryption key available]'; - } - - if (!senderPublicKey) { - console.error('[Decrypt] No sender public key provided'); - return '[Sender key missing]'; + // 1. Check Envelope Structure + // Envelope is SignedAction. + // We assume signature verified by server/trusted for now (TODO: Client verify) + + const { did: senderDid, data } = envelope; + const payloadString = data.ciphertext; // inner JSON payload + + if (!payloadString) return '[Legacy Message]'; // Fail gracefully + + const payload = JSON.parse(payloadString); + const { recipientDeviceId, senderDeviceId, ciphertext, header, iv } = payload; + + const localDeviceId = localStorage.getItem('synapsis_device_id'); + if (recipientDeviceId !== localDeviceId) return `[Message for device ${recipientDeviceId.slice(0, 4)}...]`; + + // 2. Load Session + const sessionKey = `session:${senderDid}:${senderDeviceId}`; + let state = sessionsRef.current.get(sessionKey); + if (!state) { + state = await loadEncrypted(sessionKey) || undefined; } - const myPrivateKey = await importPrivateKey(keys.privateKey); - const theirPublicKey = await importPublicKey(senderPublicKey); - const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey); + // 3. X3DH Receiver Init if needed + if (!state) { + // If it's a new session, headers MUST contain X3DH info (ik, ek, spkId, opkId) + if (!header.ik || !header.ek) return '[Invalid Init Header]'; - const combined = base64ToBuffer(encryptedMessage); + const localKeys = await loadDeviceKeys(); + if (!localKeys) return '[Keys locked]'; - if (combined.byteLength < 12) { - throw new Error('Message too short (invalid ciphertext)'); - } + // Recover keys + const senderIdentityKey = await importX25519PublicKey(header.ik); + const senderEphemeralKey = await importX25519PublicKey(header.ek); - const iv = combined.slice(0, 12); - const ciphertext = combined.slice(12); + // Find my used OTK + // Ideally we consume it and delete it. + // For now, load it. + // In V2.1 "chat_one_time_keys" table stores them. BUT we need private key locally. + // localKeys.otks is array. + // We find the one with id == header.opkId + // Caution: types for otks is Array. ID is assumed sequential/mapped? + // In generation I assigned arbitrary IDs. + // Re-check generation: `id: 100 + i`. + // I need to map ID to private key. + // In `storeDeviceKeys` I stored them as array. + // I need to match valid key. - const decrypted = await window.crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - sharedKey, - ciphertext - ); - - const decoder = new TextDecoder(); - return decoder.decode(decrypted); - } catch (error) { - console.warn('[Decrypt] Failed:', error instanceof Error ? error.message : error); - // Return a descriptive placeholder based on the error - if (error instanceof Error) { - if (error.message.includes('public key') || error.message.includes('import key')) { - return '[Incompatible encryption format]'; - } - if (error.message.includes('private key')) { - return '[Invalid private key]'; - } - if (error.message.includes('base64') || error.message.includes('decode')) { - return '[Corrupted message data]'; + let myOtk: any = undefined; + if (header.opkId) { + // Find index? ID 100 -> index 0? + const index = header.opkId - 100; + if (index >= 0 && index < localKeys.otks.length) { + myOtk = localKeys.otks[index]; + } } + + const sk = await x3dhReceiver( + localKeys.identity, + localKeys.signedPreKey, + myOtk, + senderIdentityKey, + senderEphemeralKey, + `SynapsisV2${[senderDid, identity.did].sort().join('')}${[senderDeviceId, localDeviceId].sort().join('')}` + ); + + state = await initReceiver(sk, localKeys.signedPreKey); // Using SPK pair as initial } - return '[Cannot decrypt message]'; + + // 4. Decrypt + // Reconstruct CiphertextMessage + const msgStruct: any = { + header: header, // contains dh, pn, n + ciphertext: ciphertext, + iv: iv + }; + + const { plaintext, newState } = await ratchetDecrypt(state, msgStruct); + + // 5. Update Session + sessionsRef.current.set(sessionKey, newState); + await storeEncrypted(sessionKey, newState); + + return plaintext; + + } catch (e: any) { + console.error('Decryption failed:', e); + return `[Decryption Error: ${e.message}]`; } - }, [keys]); - - // Clear keys (on logout) - const clearKeys = useCallback(() => { - localStorage.removeItem(PRIVATE_KEY_STORAGE); - localStorage.removeItem(PUBLIC_KEY_STORAGE); - setKeys(null); - }, []); + }, [isReady, identity]); return { - keys, isReady, - isRegistering, - hasKeys: !!keys, - needsPasswordToRestore, - generateAndRegisterKeys, - restoreKeysWithPassword, - encryptMessage, - decryptMessage, - clearKeys, + status, + ensureReady, + sendMessage, + decryptMessage }; } - -// ============================================ -// Password-based encryption for private key backup -// ============================================ - -async function encryptPrivateKeyWithPassword(privateKey: string, password: string): Promise { - if (typeof window === 'undefined') { - throw new Error('Encryption can only be performed in the browser'); - } - - 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 { - if (typeof window === 'undefined') { - throw new Error('Decryption can only be performed in the browser'); - } - - 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 { - if (typeof window === 'undefined') { - throw new Error('Crypto operations can only be performed in the browser'); - } - - // Validate the key format - if (!publicKeyBase64 || typeof publicKeyBase64 !== 'string') { - throw new Error('Invalid public key: must be a non-empty string'); - } - - try { - const keyBuffer = base64ToBuffer(publicKeyBase64); - - // Try SPKI format first (standard format, typically ~91 bytes for P-256) - try { - return await window.crypto.subtle.importKey( - 'spki', - keyBuffer, - { name: 'ECDH', namedCurve: 'P-256' }, - false, - [] - ); - } catch (spkiError) { - // Try raw format (65 bytes for uncompressed P-256 public key) - // Raw format is: 0x04 + X coordinate (32 bytes) + Y coordinate (32 bytes) - if (keyBuffer.byteLength === 65) { - try { - return await window.crypto.subtle.importKey( - 'raw', - keyBuffer, - { name: 'ECDH', namedCurve: 'P-256' }, - false, - [] - ); - } catch (rawError) { - // Both formats failed - } - } - - // If neither worked, throw a descriptive error - throw new Error(`Cannot import key (${keyBuffer.byteLength} bytes): incompatible format`); - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - console.warn('[ImportPublicKey] Failed:', errorMsg); - throw new Error(`Failed to import public key: ${errorMsg}`); - } -} - -async function importPrivateKey(privateKeyBase64: string): Promise { - if (typeof window === 'undefined') { - throw new Error('Crypto operations can only be performed in the browser'); - } - 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 { - if (typeof window === 'undefined') { - throw new Error('Key derivation can only be performed in the browser'); - } - - 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 { - // btoa is available in both browser and Node 16+, but let's be safe - if (typeof btoa === 'undefined') { - throw new Error('Base64 encoding not available in this environment'); - } - - 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 { - // Gracefully 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 { - // atob is available in both browser and Node 16+, but let's be safe - if (typeof atob === 'undefined') { - throw new Error('Base64 decoding not available in this environment'); - } - - 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)}`); - } -} diff --git a/src/lib/hooks/useUserIdentity.ts b/src/lib/hooks/useUserIdentity.ts index e234811..e6c4a69 100644 --- a/src/lib/hooks/useUserIdentity.ts +++ b/src/lib/hooks/useUserIdentity.ts @@ -13,7 +13,8 @@ import { generateKeyPair, exportPrivateKey, exportPublicKey, - base64UrlToBase64 + base64UrlToBase64, + createSignedAction } from '@/lib/crypto/user-signing'; export interface UserIdentity { @@ -117,6 +118,16 @@ export function useUserIdentity() { setIsUnlocked(false); }; + /** + * Sign a user action + */ + const signUserAction = async (action: string, data: any) => { + if (!identity || !isUnlocked) { + throw new Error('Identity locked'); + } + return await createSignedAction(action, data, identity.did, identity.handle); + }; + return { identity, isUnlocked, @@ -124,5 +135,6 @@ export function useUserIdentity() { unlockIdentity, lockIdentity, clearIdentity, + signUserAction }; }