sojorn/_legacy/supabase/functions/e2ee_session_manager/index.ts
2026-02-15 00:33:24 -06:00

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,
});
}
}