diff --git a/src/app/.well-known/synapsis/chat/[did]/route.ts b/src/app/.well-known/synapsis/chat/[did]/route.ts index db0030d..6c3a814 100644 --- a/src/app/.well-known/synapsis/chat/[did]/route.ts +++ b/src/app/.well-known/synapsis/chat/[did]/route.ts @@ -40,8 +40,18 @@ export async function GET( return NextResponse.json(response, { headers: { - 'Access-Control-Allow-Origin': '*', // Federation Header - 'Cache-Control': 'max-age=60' // Cache for 1 min + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'max-age=60' } }); } + +export async function OPTIONS() { + return new NextResponse(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }); +} diff --git a/src/app/api/chat/keys/fetch/route.ts b/src/app/api/chat/keys/fetch/route.ts new file mode 100644 index 0000000..0ffdc99 --- /dev/null +++ b/src/app/api/chat/keys/fetch/route.ts @@ -0,0 +1,56 @@ + +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const did = searchParams.get('did'); + const nodeDomain = searchParams.get('nodeDomain'); + + if (!did) { + return NextResponse.json({ error: 'Missing DID' }, { status: 400 }); + } + + // Determine target URL + let bundleUrl = ''; + + // If did:web, extracting domain is easy + if (did.startsWith('did:web:')) { + const parts = did.split(':'); + if (parts.length >= 4) { + const domain = parts[2]; + const protocol = domain.includes('localhost') ? 'http' : 'https'; + bundleUrl = `${protocol}://${domain}/.well-known/synapsis/chat/${did}`; + } + } + + // If did:synapsis or did:web without built-in logic, check explicit domain + if (!bundleUrl && nodeDomain) { + const protocol = nodeDomain.includes('localhost') ? 'http' : 'https'; + bundleUrl = `${protocol}://${nodeDomain}/.well-known/synapsis/chat/${did}`; + } + + if (!bundleUrl) { + return NextResponse.json({ error: 'Cannot determine remote node URL. Missing nodeDomain?' }, { status: 400 }); + } + + try { + console.log(`[Proxy] Fetching keys from: ${bundleUrl}`); + const res = await fetch(bundleUrl, { + headers: { + 'Accept': 'application/json' + } + }); + + if (!res.ok) { + const text = await res.text(); + console.error(`[Proxy] Remote fetch failed (${res.status}): ${text}`); + return NextResponse.json({ error: `Remote error: ${res.status}` }, { status: res.status }); + } + + const data = await res.json(); + return NextResponse.json(data); + } catch (error: any) { + console.error('[Proxy] Fetch error:', error); + return NextResponse.json({ error: 'Failed to fetch remote keys' }, { status: 500 }); + } +} diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 6c37d77..36f9f9f 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -258,14 +258,27 @@ export default function ChatPage() { // conversation.participant2 might have valid handle. // We resolve DID first. let did = selectedConversation.participant2.did; + // We need to support nodeDomain for existing chats too. + // But Conversation interface might lack it. + // We can try to resolve it from handle if needed, or if we stored it? + // "participant2" comes from API. + // Let's assume we re-fetch to be safe if it's remote? + // Or just check handle structure? + let nodeDomain = undefined; + if (selectedConversation.participant2.handle.includes('@')) { + const parts = selectedConversation.participant2.handle.split('@'); + if (parts.length === 2) nodeDomain = parts[1]; + } + if (!did) { const res = await fetch(`/api/users/${encodeURIComponent(selectedConversation.participant2.handle)}`); const data = await res.json(); did = data.user?.did; + nodeDomain = data.user?.nodeDomain || nodeDomain; // API is authoritative if (!did) throw new Error('User not found'); } - await sendMessage(did, newMessage); + await sendMessage(did, newMessage, nodeDomain); // Legacy UI expects message reload. setNewMessage(''); @@ -293,15 +306,20 @@ export default function ChatPage() { } // Send "Hello" to init session - await sendMessage(data.user.did, '👋'); + await sendMessage(data.user.did, '👋', data.user.nodeDomain); setShowNewChat(false); setNewChatHandle(''); loadConversations(false); // Select the new conversation (we might need to find it) // For now just reload list. - } catch (e) { - alert('Failed to start chat'); + } catch (e: any) { + console.error('Start chat failed:', e); + if (e.message.includes('Recipient keys not found')) { + alert('This user has not set up secure chat yet. They need to log in to enable end-to-end encryption.'); + } else { + alert('Failed to start chat: ' + e.message); + } } finally { setSending(false); } }; @@ -351,8 +369,27 @@ export default function ChatPage() { ); } + // Error State + if (status === 'error') { + return ( +
+ +

Connection Failed

+

+ Unable to initialize secure chat. This might be a network issue or missing keys. +

+ +
+ ); + } + // Loading State - if (status === 'initializing' || status === 'generating_keys') { + if ((!isReady && status !== 'error') || status === 'initializing' || status === 'generating_keys') { return (
diff --git a/src/lib/crypto/user-signing.ts b/src/lib/crypto/user-signing.ts index a2515f6..b2ea9b0 100644 --- a/src/lib/crypto/user-signing.ts +++ b/src/lib/crypto/user-signing.ts @@ -18,12 +18,15 @@ import { v4 as uuidv4 } from 'uuid'; export interface KeyStore { setPrivateKey(key: CryptoKey): void; getPrivateKey(): CryptoKey | null; + setIdentity(data: { did: string; handle: string; publicKey: string }): void; + getIdentity(): { did: string; handle: string; publicKey: string } | null; clear(): void; } class InMemoryKeyStore implements KeyStore { private static instance: InMemoryKeyStore; private privateKey: CryptoKey | null = null; + private identity: { did: string; handle: string; publicKey: string } | null = null; private constructor() { } @@ -45,8 +48,17 @@ class InMemoryKeyStore implements KeyStore { return this.privateKey; } + setIdentity(data: { did: string; handle: string; publicKey: string }): void { + this.identity = data; + } + + getIdentity() { + return this.identity; + } + clear(): void { this.privateKey = null; + this.identity = null; } } diff --git a/src/lib/hooks/useChatEncryption.ts b/src/lib/hooks/useChatEncryption.ts index 4a3eaa4..f061421 100644 --- a/src/lib/hooks/useChatEncryption.ts +++ b/src/lib/hooks/useChatEncryption.ts @@ -55,13 +55,29 @@ export function useChatEncryption() { if (unlocked) { try { - // If storage is open, verify keys exist - // If so, we are ready. - // If not, we might need ensureReady to generate, but usually they exist. const keys = await loadDeviceKeys(); if (keys) { setIsReady(true); setStatus('ready'); + } else if (status === 'idle' && identity?.did) { + // Keys missing but storage unlocked (and we have identity). + // Attempt to generate/restore keys. + console.log('[Chat] Storage unlocked but keys missing. Attempting generation...'); + // We pass a placeholder password because storage is already unlocked. + // We need the user ID. We can extract it from the DID or if identity has 'id'? + // identity interface in useUserIdentity: { did, handle, publicKey, isUnlocked } + // It lacks 'id' (GUID). + // BUT ensureReady takes 'userId'. + // We used 'targetUser.id' in AuthContext. + // We might need to fetch 'me' or assume DID is enough? + // ensureReady uses userId for... unlockChatStorage(pass, userId). + // If unlocked, we don't use userId? + // Let's check ensureReady usage of userId. => It is passed to unlockChatStorage. + // If unlocked, it skips unlockChatStorage. + // So userId is ignored if unlocked. Safe to pass placeholder. + ensureReady('ALREADY_UNLOCKED', 'placeholder-user-id').catch(err => { + console.error('[Chat] Auto-generation failed:', err); + }); } } catch (e) { console.error("Auto-ready check failed", e); @@ -72,7 +88,7 @@ export function useChatEncryption() { check(); // Checks immediately const interval = setInterval(check, 1000); // And polls return () => clearInterval(interval); - }, [isReady]); + }, [isReady, identity, status, signUserAction]); // ... (ensureReady, sendMessage, decryptMessage) ... @@ -138,13 +154,31 @@ export function useChatEncryption() { } }, [signUserAction]); - const sendMessage = useCallback(async (recipientDid: string, content: string) => { + const sendMessage = useCallback(async (recipientDid: string, content: string, nodeDomain?: string) => { if (!isReady || !identity) throw new Error('Chat not ready'); - // 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(); + // 1. Fetch Recipient Bundles (via Proxy to avoid CORS) + // We use our own server to fetch the keys from the remote node. + let proxyUrl = `/api/chat/keys/fetch?did=${encodeURIComponent(recipientDid)}`; + if (nodeDomain) { + proxyUrl += `&nodeDomain=${encodeURIComponent(nodeDomain)}`; + } + + let bundles: any[]; + try { + console.log(`[Chat] Fetching keys via proxy: ${proxyUrl}`); + const res = await fetch(proxyUrl); + if (!res.ok) { + const data = await res.json(); + console.error(`[Chat] Bundle fetch failed (${res.status}):`, data); + throw new Error(`Recipient keys not found (Status: ${res.status})`); + } + bundles = await res.json(); + } catch (err: any) { + console.error(`[Chat] Network error fetching bundles:`, err); + throw new Error(`Failed to resolve recipient keys: ${err.message}`); + } + const localDeviceId = localStorage.getItem('synapsis_device_id'); if (!localDeviceId) throw new Error('No local device ID'); diff --git a/src/lib/hooks/useUserIdentity.ts b/src/lib/hooks/useUserIdentity.ts index 729547c..2a61a8f 100644 --- a/src/lib/hooks/useUserIdentity.ts +++ b/src/lib/hooks/useUserIdentity.ts @@ -34,12 +34,24 @@ export function useUserIdentity() { const check = () => { const hasKey = !!keyStore.getPrivateKey(); setIsUnlocked(hasKey); - // We could also try to recover identity data if it's missing but key exists? - // But identity data usually comes from initializeIdentity + + // Auto-sync identity if available in singleton but missing in local state + const globalIdentity = keyStore.getIdentity(); + if (globalIdentity) { + setIdentity(prev => { + // Avoid rerenders if same + if (prev && prev.did === globalIdentity.did && prev.isUnlocked === hasKey) return prev; + return { ...globalIdentity, isUnlocked: hasKey }; + }); + } else { + // If global cleared, clear local + setIdentity(prev => prev ? null : null); + } }; check(); - const interval = setInterval(check, 1000); + // Poll fast to ensure UI updates are snappy + const interval = setInterval(check, 500); return () => clearInterval(interval); }, []); @@ -53,15 +65,22 @@ export function useUserIdentity() { privateKeyEncrypted: string; }, password?: string) => { + // If password provided, attempt unlock + // Save to singleton + const coreIdentity = { + did: userData.did, + handle: userData.handle, + publicKey: userData.publicKey + }; + keyStore.setIdentity(coreIdentity); + // If password provided, attempt unlock if (password) { await unlockIdentity(userData.privateKeyEncrypted, password); } else { // Just set public identity info if locked setIdentity({ - did: userData.did, - handle: userData.handle, - publicKey: userData.publicKey, + ...coreIdentity, isUnlocked: !!keyStore.getPrivateKey() }); } @@ -131,10 +150,16 @@ export function useUserIdentity() { * Sign a user action */ const signUserAction = async (action: string, data: any) => { - if (!identity || !isUnlocked) { + // Re-check global state directly to be safe + const pk = keyStore.getPrivateKey(); + const id = keyStore.getIdentity(); + + if (!id || !pk) { + console.error('[Identity] Sign failed. Identity:', id, 'HasKey:', !!pk); throw new Error('Identity locked'); } - return await createSignedAction(action, data, identity.did, identity.handle); + // Use the fetched identity to ensure sync + return await createSignedAction(action, data, id.did, id.handle); }; return {