import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; /** * E2EE Session Manager Edge Function * * This function handles session management, cleanup, and recovery for the * end-to-end encryption system. It operates on metadata only and cannot * access the actual encrypted message content. * * Security Properties: * - Blind to message content (only handles metadata) * - Enforces proper session protocols * - Handles cleanup and recovery scenarios * - Maintains perfect forward secrecy */ const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Content-Type': 'application/json', }; serve(async (req) => { // Handle CORS preflight if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }); } try { // Verify authorization header exists const authHeader = req.headers.get('Authorization'); if (!authHeader) { return new Response(JSON.stringify({ error: 'Missing authorization header' }), { status: 401, headers: corsHeaders, }); } // Create auth client to verify the user's JWT const authClient = createClient( Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', { global: { headers: { Authorization: authHeader, apikey: Deno.env.get('SUPABASE_ANON_KEY') ?? '', }, }, } ); // Verify the JWT and get the authenticated user const { data: { user }, error: authError } = await authClient.auth.getUser(); if (authError || !user) { console.error('[E2EE Session Manager] Auth error:', authError); return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders, }); } const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'); if (!serviceRoleKey) { console.error('[E2EE Session Manager] Missing SUPABASE_SERVICE_ROLE_KEY'); return new Response(JSON.stringify({ error: 'Server misconfiguration: missing service role key' }), { status: 500, headers: corsHeaders, }); } // Create service client for database operations (bypasses RLS) const supabase = createClient( Deno.env.get('SUPABASE_URL') ?? '', serviceRoleKey ); // Parse request body const { action, userId, recipientId, conversationId, hasSession } = await req.json(); if (!action) { return new Response(JSON.stringify({ error: 'Action is required' }), { status: 400, headers: corsHeaders, }); } // Use the authenticated user's ID, ignoring any userId in the request body // This prevents users from impersonating others const authenticatedUserId = user.id; console.log(`[E2EE Session Manager] ${action} requested by ${authenticatedUserId}`); switch (action) { case 'reset_session': if (!recipientId) { return new Response(JSON.stringify({ error: 'recipientId is required' }), { status: 400, headers: corsHeaders, }); } if (recipientId === authenticatedUserId) { return new Response(JSON.stringify({ error: 'recipientId must be different from userId' }), { status: 400, headers: corsHeaders, }); } { const profileCheck = await ensureProfilesExist(supabase, [authenticatedUserId, recipientId]); if (profileCheck) return profileCheck; } return handleResetSession(supabase, authenticatedUserId, recipientId, corsHeaders); case 'cleanup_conversation': if (!conversationId) { return new Response(JSON.stringify({ error: 'conversationId is required' }), { status: 400, headers: corsHeaders, }); } return handleCleanupConversation(supabase, authenticatedUserId, conversationId, corsHeaders); case 'verify_session': if (!recipientId) { return new Response(JSON.stringify({ error: 'recipientId is required' }), { status: 400, headers: corsHeaders, }); } if (recipientId === authenticatedUserId) { return new Response(JSON.stringify({ error: 'recipientId must be different from userId' }), { status: 400, headers: corsHeaders, }); } { const profileCheck = await ensureProfilesExist(supabase, [authenticatedUserId, recipientId]); if (profileCheck) return profileCheck; } return handleVerifySession(supabase, authenticatedUserId, recipientId, corsHeaders); case 'force_key_refresh': return handleForceKeyRefresh(supabase, authenticatedUserId, corsHeaders); case 'sync_session_state': if (!recipientId) { return new Response(JSON.stringify({ error: 'recipientId is required' }), { status: 400, headers: corsHeaders, }); } if (recipientId === authenticatedUserId) { return new Response(JSON.stringify({ error: 'recipientId must be different from userId' }), { status: 400, headers: corsHeaders, }); } if (typeof hasSession !== 'boolean') { return new Response(JSON.stringify({ error: 'hasSession must be a boolean' }), { status: 400, headers: corsHeaders, }); } { const profileCheck = await ensureProfilesExist(supabase, [authenticatedUserId, recipientId]); if (profileCheck) return profileCheck; } return handleSyncSessionState(supabase, authenticatedUserId, recipientId, hasSession, corsHeaders); case 'get_session_state': if (!recipientId) { return new Response(JSON.stringify({ error: 'recipientId is required' }), { status: 400, headers: corsHeaders, }); } if (recipientId === authenticatedUserId) { return new Response(JSON.stringify({ error: 'recipientId must be different from userId' }), { status: 400, headers: corsHeaders, }); } { const profileCheck = await ensureProfilesExist(supabase, [authenticatedUserId, recipientId]); if (profileCheck) return profileCheck; } return handleGetSessionState(supabase, authenticatedUserId, recipientId, corsHeaders); default: return new Response(JSON.stringify({ error: 'Invalid action' }), { status: 400, headers: corsHeaders, }); } } catch (error) { console.error('[E2EE Session Manager] Error:', error); return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: corsHeaders, }); } }); async function ensureProfilesExist( supabase: any, userIds: string[] ): Promise { const uniqueIds = [...new Set(userIds)].filter(Boolean); if (uniqueIds.length === 0) { return new Response(JSON.stringify({ error: 'Invalid user IDs' }), { status: 400, headers: corsHeaders, }); } const { data, error } = await supabase .from('profiles') .select('id') .in('id', uniqueIds); if (error) { console.error('[E2EE Session Manager] Failed to verify profiles:', error); return new Response(JSON.stringify({ error: 'Failed to verify profiles' }), { status: 500, headers: corsHeaders, }); } const found = new Set((data ?? []).map((row: { id: string }) => row.id)); const missing = uniqueIds.filter((id) => !found.has(id)); if (missing.length > 0) { return new Response(JSON.stringify({ error: 'Profile not found', missingIds: missing }), { status: 404, headers: corsHeaders, }); } return null; } /** * Reset session between two users * - Clears session keys for both parties * - Forces re-establishment of encryption session */ async function handleResetSession(supabase: any, userId: string, recipientId: string, headers: Record) { try { console.log(`[E2EE Session Manager] Resetting session between ${userId} and ${recipientId}`); // Note: We can't directly clear flutter_secure_storage from the server, // but we can send commands that the client will process // Store a session reset command in the database const { error } = await supabase .from('e2ee_session_commands') .insert({ user_id: userId, recipient_id: recipientId, command_type: 'session_reset', status: 'pending', created_at: new Date().toISOString(), }); if (error) { console.error('[E2EE Session Manager] Failed to store session reset command:', error); return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers, }); } // Send a realtime notification to both parties // The client will pick this up and clear their local session keys await supabase .from('e2ee_session_events') .insert({ user_id: userId, event_type: 'session_reset', recipient_id: recipientId, timestamp: new Date().toISOString(), }); await supabase .from('e2ee_session_events') .insert({ user_id: recipientId, event_type: 'session_reset', recipient_id: userId, timestamp: new Date().toISOString(), }); return new Response(JSON.stringify({ success: true, message: 'Session reset initiated for both parties' }), { status: 200, headers, }); } catch (error) { console.error('[E2EE Session Manager] Session reset failed:', error); return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers, }); } } /** * Clean up conversation and related data * - Deletes messages (if allowed by RLS) * - Clears session keys * - Handles cleanup of related metadata */ async function handleCleanupConversation(supabase: any, userId: string, conversationId: string, headers: Record) { try { console.log(`[E2EE Session Manager] Cleaning up conversation ${conversationId} for ${userId}`); // Get conversation details const { data: conversation, error: convError } = await supabase .from('encrypted_conversations') .select('participant_a, participant_b') .eq('id', conversationId) .single(); if (convError || !conversation) { console.error('[E2EE Session Manager] Conversation not found:', convError); return new Response(JSON.stringify({ success: false, error: 'Conversation not found' }), { status: 404, headers, }); } // Determine the other participant const otherUserId = conversation.participant_a === userId ? conversation.participant_b : conversation.participant_a; // Delete messages sent by the current user const { error: deleteError } = await supabase .from('encrypted_messages') .delete() .eq('conversation_id', conversationId) .eq('sender_id', userId); if (deleteError) { console.warn('[E2EE Session Manager] Could not delete all messages:', deleteError); // This is acceptable - RLS might prevent deletion of some messages } // Store cleanup command for both parties await supabase .from('e2ee_session_commands') .insert({ user_id: userId, conversation_id: conversationId, command_type: 'conversation_cleanup', status: 'pending', created_at: new Date().toISOString(), }); await supabase .from('e2ee_session_commands') .insert({ user_id: otherUserId, conversation_id: conversationId, command_type: 'conversation_cleanup', status: 'pending', created_at: new Date().toISOString(), }); // Send realtime notifications await supabase .from('e2ee_session_events') .insert({ user_id: userId, event_type: 'conversation_cleanup', conversation_id: conversationId, timestamp: new Date().toISOString(), }); await supabase .from('e2ee_session_events') .insert({ user_id: otherUserId, event_type: 'conversation_cleanup', conversation_id: conversationId, timestamp: new Date().toISOString(), }); return new Response(JSON.stringify({ success: true, message: 'Conversation cleanup initiated', otherUserId: otherUserId }), { status: 200, headers, }); } catch (error) { console.error('[E2EE Session Manager] Conversation cleanup failed:', error); return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers, }); } } /** * Verify if a session exists between two users * - Checks for existing session metadata * - Returns session status without exposing keys */ async function handleVerifySession(supabase: any, userId: string, recipientId: string, headers: Record) { try { console.log(`[E2EE Session Manager] Verifying session between ${userId} and ${recipientId}`); // Check if both users have signal keys (indicates they can establish sessions) const { data: userKeys, error: userError } = await supabase .from('signal_keys') .select('user_id') .eq('user_id', userId) .single(); const { data: recipientKeys, error: recipientError } = await supabase .from('signal_keys') .select('user_id') .eq('user_id', recipientId) .single(); if (userError || !userKeys) { return new Response(JSON.stringify({ success: false, error: 'User has no encryption keys', userHasKeys: false, recipientHasKeys: !!recipientKeys }), { status: 400, headers, }); } if (recipientError || !recipientKeys) { return new Response(JSON.stringify({ success: false, error: 'Recipient has no encryption keys', userHasKeys: true, recipientHasKeys: false }), { status: 400, headers, }); } // Check if there's an existing conversation (indicates potential session) const { data: conversation, error: convError } = await supabase .from('encrypted_conversations') .select('id, last_message_at') .or(`participant_a.eq.${userId},participant_b.eq.${userId}`) .or(`participant_a.eq.${recipientId},participant_b.eq.${recipientId}`) .single(); return new Response(JSON.stringify({ success: true, userHasKeys: true, recipientHasKeys: true, hasConversation: !!conversation, conversationId: conversation?.id, lastMessageAt: conversation?.last_message_at }), { status: 200, headers, }); } catch (error) { console.error('[E2EE Session Manager] Session verification failed:', error); return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers, }); } } /** * Force key refresh for a user * - Triggers rotation of encryption keys * - Handles key upload and cleanup */ async function handleForceKeyRefresh(supabase: any, userId: string, headers: Record) { try { console.log(`[E2EE Session Manager] Forcing key refresh for ${userId}`); // Store a key refresh command const { error } = await supabase .from('e2ee_session_commands') .insert({ user_id: userId, command_type: 'key_refresh', status: 'pending', created_at: new Date().toISOString(), }); if (error) { console.error('[E2EE Session Manager] Failed to store key refresh command:', error); return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers, }); } // Send realtime notification await supabase .from('e2ee_session_events') .insert({ user_id: userId, event_type: 'key_refresh', timestamp: new Date().toISOString(), }); return new Response(JSON.stringify({ success: true, message: 'Key refresh initiated', note: 'Client should generate new keys and upload them' }), { status: 200, headers, }); } catch (error) { console.error('[E2EE Session Manager] Key refresh failed:', error); return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers, }); } } /** * Sync session state between two users * - Updates the server-side session state tracking * - Detects mismatches and triggers recovery if needed */ async function handleSyncSessionState( supabase: any, userId: string, recipientId: string, hasSession: boolean, headers: Record ) { try { console.log(`[E2EE Session Manager] Syncing session state: ${userId} -> ${recipientId}, hasSession: ${hasSession}`); if (!recipientId) { return new Response(JSON.stringify({ error: 'recipientId is required' }), { status: 400, headers, }); } // Call the database function to update session state const { data, error } = await supabase.rpc('update_e2ee_session_state', { p_user_id: userId, p_peer_id: recipientId, p_has_session: hasSession, }); if (error) { console.error('[E2EE Session Manager] Failed to update session state:', error); return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers, }); } const result = data as { success: boolean; user_has_session: boolean; peer_has_session: boolean; session_mismatch: boolean; peer_session_version: number; }; // If there's a mismatch, notify both parties if (result.session_mismatch) { console.log(`[E2EE Session Manager] Session mismatch detected between ${userId} and ${recipientId}`); // Insert session_mismatch event for both users await supabase.from('e2ee_session_events').insert([ { user_id: userId, event_type: 'session_mismatch', recipient_id: recipientId, error_details: { user_has_session: result.user_has_session, peer_has_session: result.peer_has_session, }, timestamp: new Date().toISOString(), }, { user_id: recipientId, event_type: 'session_mismatch', recipient_id: userId, error_details: { user_has_session: result.peer_has_session, peer_has_session: result.user_has_session, }, timestamp: new Date().toISOString(), }, ]); } return new Response(JSON.stringify({ success: true, userHasSession: result.user_has_session, peerHasSession: result.peer_has_session, sessionMismatch: result.session_mismatch, peerSessionVersion: result.peer_session_version, }), { status: 200, headers, }); } catch (error) { console.error('[E2EE Session Manager] Sync session state failed:', error); return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers, }); } } /** * Get current session state between two users * - Returns session state without modifying it * - Used to check for mismatches before sending messages */ async function handleGetSessionState( supabase: any, userId: string, recipientId: string, headers: Record ) { try { console.log(`[E2EE Session Manager] Getting session state: ${userId} <-> ${recipientId}`); if (!recipientId) { return new Response(JSON.stringify({ error: 'recipientId is required' }), { status: 400, headers, }); } // Call the database function to get session state const { data, error } = await supabase.rpc('get_e2ee_session_state', { p_user_id: userId, p_peer_id: recipientId, }); if (error) { console.error('[E2EE Session Manager] Failed to get session state:', error); return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers, }); } const result = data as { exists: boolean; user_has_session: boolean; peer_has_session: boolean; session_mismatch: boolean; user_session_version?: number; peer_session_version?: number; }; return new Response(JSON.stringify({ success: true, exists: result.exists, userHasSession: result.user_has_session, peerHasSession: result.peer_has_session, sessionMismatch: result.session_mismatch, userSessionVersion: result.user_session_version, peerSessionVersion: result.peer_session_version, }), { status: 200, headers, }); } catch (error) { console.error('[E2EE Session Manager] Get session state failed:', error); return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers, }); } }