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:
Christopher
2026-01-27 19:34:37 -08:00
parent 33295b744e
commit 7eae96bc44
6 changed files with 198 additions and 24 deletions
@@ -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',
},
});
}
+56
View File
@@ -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
View File
@@ -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} />
+12
View File
@@ -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;
}
}
+43 -9
View File
@@ -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');
+33 -8
View File
@@ -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 {