diff --git a/src/app/api/chat/keys/fetch/route.ts b/src/app/api/chat/keys/fetch/route.ts index 0ffdc99..fb263e7 100644 --- a/src/app/api/chat/keys/fetch/route.ts +++ b/src/app/api/chat/keys/fetch/route.ts @@ -10,47 +10,49 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing DID' }, { status: 400 }); } - // Determine target URL - let bundleUrl = ''; + const handle = searchParams.get('handle'); + // Helper to fetch and check + const tryFetch = async (url: string) => { + console.log(`[Proxy] Fetching keys from: ${url}`); + const res = await fetch(url, { headers: { 'Accept': 'application/json' } }); + if (res.ok) return await res.json(); + const text = await res.text(); + console.warn(`[Proxy] Fetch failed for ${url} (${res.status}): ${text}`); + return null; + }; + + // 1. Try Primary DID + let primaryUrl = ''; // 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}`; + primaryUrl = `${protocol}://${domain}/.well-known/synapsis/chat/${did}`; } - } - - // If did:synapsis or did:web without built-in logic, check explicit domain - if (!bundleUrl && nodeDomain) { + } else if (nodeDomain) { const protocol = nodeDomain.includes('localhost') ? 'http' : 'https'; - bundleUrl = `${protocol}://${nodeDomain}/.well-known/synapsis/chat/${did}`; + primaryUrl = `${protocol}://${nodeDomain}/.well-known/synapsis/chat/${did}`; } - if (!bundleUrl) { - return NextResponse.json({ error: 'Cannot determine remote node URL. Missing nodeDomain?' }, { status: 400 }); + if (primaryUrl) { + const data = await tryFetch(primaryUrl); + if (data) return NextResponse.json(data); } - try { - console.log(`[Proxy] Fetching keys from: ${bundleUrl}`); - const res = await fetch(bundleUrl, { - headers: { - 'Accept': 'application/json' - } - }); + // 2. Try Fallback: did:web (if handle and domain provided) + // The remote user might be indexed by did:web even if we know them as did:synapsis + if (nodeDomain && handle) { + const didWeb = `did:web:${nodeDomain}:${handle}`; + const protocol = nodeDomain.includes('localhost') ? 'http' : 'https'; + const fallbackUrl = `${protocol}://${nodeDomain}/.well-known/synapsis/chat/${didWeb}`; - 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 }); + console.log(`[Proxy] Primary failed. Trying fallback: ${didWeb}`); + const data = await tryFetch(fallbackUrl); + if (data) return NextResponse.json(data); } + + return NextResponse.json({ error: 'Remote keys not found (checked primary and fallback)' }, { status: 404 }); } diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 36f9f9f..9c28de9 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -278,7 +278,7 @@ export default function ChatPage() { if (!did) throw new Error('User not found'); } - await sendMessage(did, newMessage, nodeDomain); + await sendMessage(did, newMessage, nodeDomain, selectedConversation.participant2.handle); // Legacy UI expects message reload. setNewMessage(''); @@ -306,7 +306,7 @@ export default function ChatPage() { } // Send "Hello" to init session - await sendMessage(data.user.did, '👋', data.user.nodeDomain); + await sendMessage(data.user.did, '👋', data.user.nodeDomain, data.user.handle); setShowNewChat(false); setNewChatHandle(''); diff --git a/src/lib/hooks/useChatEncryption.ts b/src/lib/hooks/useChatEncryption.ts index f061421..66e748e 100644 --- a/src/lib/hooks/useChatEncryption.ts +++ b/src/lib/hooks/useChatEncryption.ts @@ -57,24 +57,44 @@ export function useChatEncryption() { try { const keys = await loadDeviceKeys(); if (keys) { + // Keys exist locally. setIsReady(true); setStatus('ready'); + + // CRITICAL REPAIR: + // Just because we have keys doesn't mean the server does. + // We run a non-blocking check to ensure we aren't a "Zombie". + // We only do this check if we haven't verified it this session yet. + if (identity?.did && !sessionStorage.getItem('synapsis_keys_verified')) { + fetch(`/.well-known/synapsis/chat/${identity.did}`).then(async (res) => { + if (res.status === 404) { + console.warn('[Chat] Zombie State Detected during init. Republishing keys...'); + // Extract publish logic (duplicated for now to ensure safety without massive refactor) + try { + const deviceId = localStorage.getItem('synapsis_device_id') || uuidv4(); + const bundlePayload = { + deviceId, + identityKey: await exportKey(keys.identity.publicKey), + signedPreKey: { id: 1, key: await exportKey(keys.signedPreKey.publicKey) }, + oneTimeKeys: await Promise.all(keys.otks.map(async (k: any, i: number) => ({ id: 100 + i, key: await exportKey(k.publicKey) }))) + }; + const signedAction = await signUserAction('chat.keys.publish', bundlePayload); + await fetch('/api/chat/keys', { method: 'POST', body: JSON.stringify(signedAction) }); + console.log('[Chat] Self-repair successful.'); + sessionStorage.setItem('synapsis_keys_verified', 'true'); + } catch (e) { + console.error('[Chat] Self-repair failed:', e); + } + } else if (res.ok) { + sessionStorage.setItem('synapsis_keys_verified', 'true'); + } + }).catch(e => console.error('Verification check failed', e)); + } + } 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); }); @@ -102,30 +122,23 @@ export function useChatEncryption() { } 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); + // Helper to publish keys + const publishKeys = async (k: any) => { + const deviceId = localStorage.getItem('synapsis_device_id') || uuidv4(); + if (!localStorage.getItem('synapsis_device_id')) { + localStorage.setItem('synapsis_device_id', deviceId); + } const bundlePayload = { deviceId, - identityKey: await exportKey(identityKey.publicKey), + identityKey: await exportKey(k.identity.publicKey), signedPreKey: { id: 1, - key: await exportKey(signedPreKey.publicKey), + key: await exportKey(k.signedPreKey.publicKey), }, - oneTimeKeys: await Promise.all(otks.map(async (k, i) => ({ + oneTimeKeys: await Promise.all(k.otks.map(async (ko: any, i: number) => ({ id: 100 + i, - key: await exportKey(k.publicKey) + key: await exportKey(ko.publicKey) }))) }; @@ -138,8 +151,36 @@ export function useChatEncryption() { }); if (!res.ok) throw new Error('Failed to publish keys'); + console.log('[Chat] Keys published successfully'); + }; + + if (!keys) { + setStatus('generating_keys'); + const identityKey = await generateX25519KeyPair(); + const signedPreKey = await generatePreKey(); + const otks = await Promise.all(Array.from({ length: 5 }).map(() => generatePreKey())); keys = { identity: identityKey, signedPreKey, otks }; + await storeDeviceKeys(identityKey, signedPreKey, otks); + await publishKeys(keys); + } else { + // Self-Repair: Check if server actually has our keys. + // If the user is in a "Zombie State" (local keys but server 404), we must republish. + try { + // Check only if we have a DID + if (identity?.did) { + const checkRes = await fetch(`/.well-known/synapsis/chat/${identity.did}`); + if (checkRes.status === 404) { + console.warn('[Chat] Detected Zombie State: Local keys exist but server returned 404. Republishing...'); + await publishKeys(keys); + } else { + // Also check if OUR deviceId is in the bundle list? + // For now, 404 check is the critical fix for the reported issue. + } + } + } catch (repairErr) { + console.error('[Chat] Self-repair check failed:', repairErr); + } } // Restore session cache? @@ -154,7 +195,7 @@ export function useChatEncryption() { } }, [signUserAction]); - const sendMessage = useCallback(async (recipientDid: string, content: string, nodeDomain?: string) => { + const sendMessage = useCallback(async (recipientDid: string, content: string, nodeDomain?: string, recipientHandle?: string) => { if (!isReady || !identity) throw new Error('Chat not ready'); // 1. Fetch Recipient Bundles (via Proxy to avoid CORS) @@ -163,6 +204,9 @@ export function useChatEncryption() { if (nodeDomain) { proxyUrl += `&nodeDomain=${encodeURIComponent(nodeDomain)}`; } + if (recipientHandle) { + proxyUrl += `&handle=${encodeURIComponent(recipientHandle)}`; + } let bundles: any[]; try { @@ -188,7 +232,12 @@ export function useChatEncryption() { // 2. Loop through all devices for (const bundle of bundles) { - const sessionKey = `session:${recipientDid}:${bundle.deviceId}`; + // IMPORTANT: Use the DID from the bundle! + // This handles the "Aliasing" case where we asked for did:synapsis but got did:web keys. + // The session should be bound to the DID that signed the keys. + const targetDid = bundle.did || recipientDid; + + const sessionKey = `session:${targetDid}:${bundle.deviceId}`; let state = sessionsRef.current.get(sessionKey); if (!state) { @@ -207,7 +256,7 @@ export function useChatEncryption() { const { sk, ephemeralKey } = await x3dhSender( localKeys.identity, { identityKey: remoteIdentityKey, signedPreKey: remoteSignedPreKey, oneTimeKey: remoteOtk }, - `SynapsisV2${[identity.did, recipientDid].sort().join('')}${[localDeviceId, bundle.deviceId].sort().join('')}` + `SynapsisV2${[identity.did, targetDid].sort().join('')}${[localDeviceId, bundle.deviceId].sort().join('')}` ); state = await initSender(sk, remoteSignedPreKey);