Add remote key fetch proxy and improve chat reliability
Introduces an API route to proxy remote key bundle fetches, improving CORS handling for secure chat. Updates chat UI to handle error states and retries, enhances key/identity synchronization, and refactors message sending to support remote node domains. Also adds CORS preflight support to the .well-known chat route and improves in-memory key store management.
This commit is contained in:
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+42
-5
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center', gap: '16px' }}>
|
||||
<Shield size={48} style={{ color: 'var(--destructive)' }} />
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 600 }}>Connection Failed</h2>
|
||||
<p style={{ color: 'var(--foreground-secondary)', maxWidth: '300px', textAlign: 'center' }}>
|
||||
Unable to initialize secure chat. This might be a network issue or missing keys.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => ensureReady('RETRY', user?.id || 'retry')}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Retry Connection
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading State
|
||||
if (status === 'initializing' || status === 'generating_keys') {
|
||||
if ((!isReady && status !== 'error') || status === 'initializing' || status === 'generating_keys') {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Loader2 className="animate-spin" size={32} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user