Improve chat key fetch and self-repair logic
Refactors the chat key fetch API to support fallback lookups using user handle and did:web aliasing, improving reliability when primary lookups fail. Updates useChatEncryption to detect and repair 'Zombie State' where local keys exist but are missing on the server, ensuring keys are republished as needed. Modifies sendMessage to pass recipient handle and bind sessions to the correct DID, supporting aliasing scenarios.
This commit is contained in:
@@ -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 });
|
||||
console.log(`[Proxy] Primary failed. Trying fallback: ${didWeb}`);
|
||||
const data = await tryFetch(fallbackUrl);
|
||||
if (data) return NextResponse.json(data);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Remote keys not found (checked primary and fallback)' }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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();
|
||||
// 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);
|
||||
|
||||
await storeDeviceKeys(identityKey, signedPreKey, otks);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user