687 lines
21 KiB
TypeScript
687 lines
21 KiB
TypeScript
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<Response | null> {
|
|
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<string, string>) {
|
|
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<string, string>) {
|
|
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<string, string>) {
|
|
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<string, string>) {
|
|
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<string, string>
|
|
) {
|
|
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<string, string>
|
|
) {
|
|
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,
|
|
});
|
|
}
|
|
}
|