Implement E2EE and message content moderation

This commit is contained in:
Jamie Brunton committed 2026-01-02 20:49:11 +00:00
1 parent e1ff87dc03
commit 198cd80dfd
74 files changed
+11568 -807

No files matched your search

+37
View File
@@ -176,3 +176,40 @@ backgroundColor: 'rgba(18, 18, 18, 0.85)',
The `BlurView` in `tabBarBackground` is unreliable and can fail during re-renders/transitions, causing a fully transparent tab bar. The solid rgba background acts as a fallback while still allowing the blur effect on top. The `BlurView` in `tabBarBackground` is unreliable and can fail during re-renders/transitions, causing a fully transparent tab bar. The solid rgba background acts as a fallback while still allowing the blur effect on top.
**Location:** `apps/mobile/app/(app)/_layout.tsx` - `tabBarStyle` **Location:** `apps/mobile/app/(app)/_layout.tsx` - `tabBarStyle`
## E2EE (End-to-End Encryption) Implementation
Chat messages (text, images, videos) are encrypted client-side using AES-256-GCM with triple-wrapped RSA keys.
### Architecture
- **Full documentation:** See `docs/e2ee-implementation.md` for complete details
- **Encryption library:** `apps/mobile/src/lib/encryption/`
- **React hooks:** `apps/mobile/src/hooks/useEncryptionKeys.ts`, `useDecryptedMessage.ts`
- **Edge functions:** `handle-key-rotation`, `reencrypt-pending-messages`
### Key Concepts
1. **Message versions:** v1 = plaintext (legacy), v2 = E2EE
2. **Triple-wrapped keys:** Each message's AES key is RSA-encrypted for sender, recipient, and admin
3. **Device recovery:** When user gets new device, admin key re-wraps messages for new user key
### Database Tables
- `messages.version` - Encryption version (1 or 2)
- `messages.encrypted_content` - Base64 ciphertext
- `messages.encryption_iv` - Base64 IV for AES-GCM
- `messages.keys_metadata` - JSONB with wrapped keys
- `profiles.public_key_jwk` - User's RSA public key
- `master_keys` - Admin RSA public keys
### Setup Required
1. Run `node scripts/generate-admin-keys.mjs` to generate admin keypair
2. Insert public key into `master_keys` table
3. Store private key in `ADMIN_PRIVATE_KEY_JWK` environment variable for edge functions
4. Install crypto dependency: `npm install react-native-quick-crypto@^0.7.x`
5. Run `npx expo prebuild --clean` after installation
### Edge Functions for E2EE
| Function | verify_jwt | Purpose |
|----------|------------|---------|
| `handle-key-rotation` | false | Re-wraps message keys when user gets new device |
| `reencrypt-pending-messages` | false | Wraps keys for recipient after they generate keys |
+9
View File
@@ -18,6 +18,7 @@ import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
import { UserActivityPage } from '@/pages/activity/UserActivityPage'; import { UserActivityPage } from '@/pages/activity/UserActivityPage';
import { AiSettingsPage } from '@/pages/AiSettingsPage'; import { AiSettingsPage } from '@/pages/AiSettingsPage';
import { AppSettingsPage } from '@/pages/AppSettingsPage'; import { AppSettingsPage } from '@/pages/AppSettingsPage';
import { FlaggedMessagesPage } from '@/pages/FlaggedMessagesPage';
function App() { function App() {
return ( return (
@@ -147,6 +148,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/flagged-messages"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}>
<FlaggedMessagesPage />
</ProtectedRoute>
}
/>
<Route <Route
path="/app-settings" path="/app-settings"
element={ element={
@@ -17,6 +17,7 @@ import {
X, X,
Home, Home,
Activity, Activity,
AlertTriangle,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -82,6 +83,12 @@ const systemNav: NavItem[] = [
icon: <Shield className="h-5 w-5" />, icon: <Shield className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_ADMINS, requiredPermission: PERMISSION_KEYS.MANAGE_ADMINS,
}, },
{
label: 'Flagged Messages',
href: '/flagged-messages',
icon: <AlertTriangle className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG,
},
{ {
label: 'AI Settings', label: 'AI Settings',
href: '/ai-settings', href: '/ai-settings',
+3
View File
@@ -20,6 +20,9 @@ export interface AiConfig {
council_reviewer_model: string | null; council_reviewer_model: string | null;
council_selection_mode: CouncilSelectionMode | null; // 'whole_set' or 'cherry_pick' council_selection_mode: CouncilSelectionMode | null; // 'whole_set' or 'cherry_pick'
cherry_pick_ensure_intensity_distribution: boolean | null; // Balance intensity levels in cherry-pick mode cherry_pick_ensure_intensity_distribution: boolean | null; // Balance intensity levels in cherry-pick mode
classifier_enabled: boolean | null;
classifier_model: string | null;
classifier_prompt: string | null;
updated_at: string | null; updated_at: string | null;
updated_by: string | null; updated_by: string | null;
} }
+61 -1
View File
@@ -9,7 +9,8 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry } from 'lucide-react'; import { Textarea } from '@/components/ui/textarea';
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ModelCombobox } from '@/components/ModelCombobox'; import { ModelCombobox } from '@/components/ModelCombobox';
@@ -47,6 +48,9 @@ export function AiSettingsPage() {
council_reviewer_model: config.council_reviewer_model || '', council_reviewer_model: config.council_reviewer_model || '',
council_selection_mode: config.council_selection_mode || 'whole_set', council_selection_mode: config.council_selection_mode || 'whole_set',
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true, cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_prompt: config.classifier_prompt || '',
}); });
setHasChanges(false); setHasChanges(false);
} }
@@ -92,6 +96,9 @@ export function AiSettingsPage() {
council_reviewer_model: config.council_reviewer_model || '', council_reviewer_model: config.council_reviewer_model || '',
council_selection_mode: config.council_selection_mode || 'whole_set', council_selection_mode: config.council_selection_mode || 'whole_set',
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true, cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_prompt: config.classifier_prompt || '',
}); });
setHasChanges(false); setHasChanges(false);
} }
@@ -248,6 +255,59 @@ export function AiSettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Moderation Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Moderation Settings
</CardTitle>
<CardDescription>
Configure automatic content moderation for messages
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Enable Auto-Moderation</Label>
<p className="text-xs text-muted-foreground">
Automatically flag illegal/dangerous content
</p>
</div>
<Switch
checked={formData.classifier_enabled ?? true}
onCheckedChange={(checked) => handleChange('classifier_enabled', checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="classifier-model">Classifier Model</Label>
<ModelCombobox
value={formData.classifier_model || ''}
onChange={(value) => handleChange('classifier_model', value)}
placeholder="openai/gpt-4o"
/>
<p className="text-xs text-muted-foreground">
Model used for classification (must support vision if image moderation is needed)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="classifier-prompt">System Prompt</Label>
<Textarea
id="classifier-prompt"
value={formData.classifier_prompt || ''}
onChange={(e) => handleChange('classifier_prompt', e.target.value)}
className="h-32 font-mono text-sm"
placeholder="You are a content moderator..."
/>
<p className="text-xs text-muted-foreground">
Instructions for the AI classifier.
</p>
</div>
</CardContent>
</Card>
{/* Model Configuration */} {/* Model Configuration */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -0,0 +1,323 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/config';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Shield, CheckCircle, AlertTriangle, Eye, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { format } from 'date-fns';
interface FlaggedMessage {
id: string;
created_at: string;
user_id: string;
moderation_status: 'flagged' | 'safe' | 'unmoderated';
flag_reason: string | null;
content?: string; // Encrypted usually
media_path?: string;
media_type?: string;
}
export function FlaggedMessagesPage() {
const [messages, setMessages] = useState<FlaggedMessage[]>([]);
const [loading, setLoading] = useState(true);
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
const [decryptedContent, setDecryptedContent] = useState<{ text: string | null; mediaUrl: string | null }>({ text: null, mediaUrl: null });
const [decrypting, setDecrypting] = useState(false);
const [processing, setProcessing] = useState<string | null>(null); // messageId being processed
const fetchMessages = async () => {
setLoading(true);
try {
const { data, error } = await supabase
.from('messages')
.select('id, created_at, user_id, moderation_status, flag_reason, content, media_path, media_type')
.eq('moderation_status', 'flagged')
.order('created_at', { ascending: false });
if (error) throw error;
setMessages(data || []);
} catch (error) {
console.error('Failed to load flagged messages:', error);
toast.error('Failed to load messages');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMessages();
}, []);
const handleView = async (message: FlaggedMessage) => {
setSelectedMessage(message);
setDecrypting(true);
setDecryptedContent({ text: null, mediaUrl: null });
try {
// Call edge function to decrypt
// We use admin-decrypt-message for text
let text = null;
let mediaUrl = null;
// 1. Decrypt Text/Metadata
const { data: decryptData, error: decryptError } = await supabase.functions.invoke('admin-decrypt-message', {
body: { messageId: message.id }
});
if (decryptError) throw decryptError;
if (decryptData) {
text = decryptData.content;
// 2. Handle Media
// If it has media, admin-decrypt-message returns media_path but not the file content directly decrypted usually
// But admin-decrypt-media function returns the raw bytes
if (decryptData.media_path) {
const { data: mediaBlob, error: mediaError } = await supabase.functions.invoke('admin-decrypt-media', {
body: { messageId: message.id },
// Response is a blob/file usually?
// supabase.functions.invoke by default parses JSON.
// We might need to handle blob response.
});
// Invoke returns data as parsed JSON if header is json, or blob?
// The admin-decrypt-media returns binary body.
// Supabase js client might try to parse it.
// We might need a direct fetch or handle responseType.
}
}
// NOTE: For now, displaying text content. Media decryption in frontend via edge function binary response
// is tricky with supabase-js invoke().
// We will just show the text for now or implement media later if critical.
// Actually, we can just use a direct fetch with the user's session token.
setDecryptedContent({ text, mediaUrl });
} catch (error) {
console.error('Decryption failed:', error);
toast.error('Failed to decrypt content');
setDecryptedContent({ text: 'Error decrypting content', mediaUrl: null });
} finally {
setDecrypting(false);
}
};
// Custom media fetcher because supabase.functions.invoke is JSON-centric
const fetchDecryptedMedia = async (messageId: string) => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) return null;
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.access_token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ messageId })
});
if (!response.ok) throw new Error('Failed to fetch media');
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (e) {
console.error(e);
return null;
}
};
// Improved handleView with media support
const handleViewComplete = async (message: FlaggedMessage) => {
setSelectedMessage(message);
setDecrypting(true);
setDecryptedContent({ text: null, mediaUrl: null });
try {
// Text
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
body: { messageId: message.id }
});
let mediaUrl = null;
if (message.media_path) {
mediaUrl = await fetchDecryptedMedia(message.id);
}
setDecryptedContent({
text: textData?.content || (textError ? 'Error decrypting text' : null),
mediaUrl
});
} catch (e) {
toast.error("Error loading content");
} finally {
setDecrypting(false);
}
};
const handleMarkSafe = async (messageId: string) => {
setProcessing(messageId);
try {
const { error } = await supabase
.from('messages')
.update({ moderation_status: 'safe' })
.eq('id', messageId);
if (error) throw error;
toast.success('Message marked as safe');
setMessages(prev => prev.filter(m => m.id !== messageId));
if (selectedMessage?.id === messageId) setSelectedMessage(null);
} catch (error) {
toast.error('Failed to update status');
} finally {
setProcessing(null);
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Flagged Messages</h1>
<p className="text-muted-foreground">
Review content flagged by AI moderation
</p>
</div>
<Button variant="outline" onClick={fetchMessages} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{messages.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<CheckCircle className="h-8 w-8 text-green-500/50" />
<p>No flagged messages found. Good job!</p>
</div>
</TableCell>
</TableRow>
) : (
messages.map((message) => (
<TableRow key={message.id}>
<TableCell className="font-mono text-sm">
{format(new Date(message.created_at), 'MMM d, HH:mm')}
</TableCell>
<TableCell>
<Badge variant="destructive" className="bg-red-500/10 text-red-500 hover:bg-red-500/20 border-0">
{message.flag_reason || 'Unknown'}
</Badge>
</TableCell>
<TableCell>
{message.media_path ? (
<Badge variant="outline">{message.media_type || 'Media'}</Badge>
) : (
<Badge variant="secondary">Text</Badge>
)}
</TableCell>
<TableCell className="text-right space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewComplete(message)}
>
<Eye className="h-4 w-4 mr-2" />
Review
</Button>
<Button
variant="outline"
size="sm"
className="text-green-600 hover:text-green-700 hover:bg-green-50"
onClick={() => handleMarkSafe(message.id)}
disabled={processing === message.id}
>
<CheckCircle className="h-4 w-4 mr-2" />
Mark Safe
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
<Dialog open={!!selectedMessage} onOpenChange={(open) => !open && setSelectedMessage(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Review Content</DialogTitle>
<DialogDescription>
Decrypted content for review. Flagged for: {selectedMessage?.flag_reason}
</DialogDescription>
</DialogHeader>
<div className="min-h-[200px] bg-muted/30 rounded-md p-4 flex items-center justify-center">
{decrypting ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<RefreshCw className="h-6 w-6 animate-spin" />
<p className="text-sm">Decrypting secure content...</p>
</div>
) : (
<div className="w-full space-y-4">
{decryptedContent.text && (
<div className="bg-background border rounded p-3 text-sm">
{decryptedContent.text}
</div>
)}
{decryptedContent.mediaUrl && (
<div className="flex justify-center">
{selectedMessage?.media_type === 'video' ? (
<video src={decryptedContent.mediaUrl} controls className="max-h-[400px] rounded" />
) : (
<img src={decryptedContent.mediaUrl} alt="Flagged content" className="max-h-[400px] rounded object-contain" />
)}
</div>
)}
{!decryptedContent.text && !decryptedContent.mediaUrl && (
<p className="text-center text-muted-foreground">No content displayed</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setSelectedMessage(null)}>Cancel</Button>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={() => selectedMessage && handleMarkSafe(selectedMessage.id)}
>
Mark Safe
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+188 -57
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { supabase } from '@/config'; import { supabase, supabaseConfig } from '@/config';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -36,6 +37,10 @@ interface Message {
created_at: string; created_at: string;
read_at: string | null; read_at: string | null;
media_viewed_at: string | null; media_viewed_at: string | null;
version?: number | null;
encrypted_content?: string | null;
encryption_iv?: string | null;
keys_metadata?: Record<string, unknown> | null;
} }
const matchTypeLabels = { const matchTypeLabels = {
@@ -55,44 +60,72 @@ function formatMessageTime(date: Date): string {
function ChatImage({ mediaPath }: { mediaPath: string }) { function ChatImage({ messageId }: { messageId: string }) {
const { session, isSuperAdmin } = useAuth();
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!mediaPath) return; if (!messageId) return;
const getUrl = async () => {
try {
// Handle legacy full URLs
let path = mediaPath;
if (path.startsWith('http')) {
const parts = path.split('/chat-media/');
if (parts.length > 1) {
path = decodeURIComponent(parts[1]);
}
}
if (!isSuperAdmin) {
const { data, error } = await supabase.storage setError('Decryption requires super admin');
.from('chat-media')
.createSignedUrl(path, 3600);
if (error) {
console.error('Error creating signed URL:', error);
setError(error.message);
return; return;
} }
if (data?.signedUrl) { if (!session?.access_token) {
setUrl(data.signedUrl); setError('Not authenticated');
return;
} }
let cancelled = false;
let objectUrl: string | null = null;
const load = async () => {
try {
setError(null);
setUrl(null);
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
apikey: supabaseConfig.anonKey,
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ messageId }),
});
if (!res.ok) {
const raw = await res.text().catch(() => '');
let message = raw;
try {
const parsed = JSON.parse(raw) as { error?: unknown };
if (typeof parsed?.error === 'string') message = parsed.error;
} catch {
// ignore
}
throw new Error(message || `Failed to load media (${res.status})`);
}
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob);
if (cancelled) return;
setUrl(objectUrl);
} catch (err) { } catch (err) {
console.error('Unexpected error loading image:', err); if (cancelled) return;
setError('Failed to load'); setError(err instanceof Error ? err.message : 'Failed to load');
} }
}; };
getUrl();
}, [mediaPath]); load();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [messageId, isSuperAdmin, session?.access_token]);
if (error) { if (error) {
return ( return (
@@ -118,43 +151,72 @@ function ChatImage({ mediaPath }: { mediaPath: string }) {
} }
function ChatVideo({ mediaPath }: { mediaPath: string }) { function ChatVideo({ messageId }: { messageId: string }) {
const { session, isSuperAdmin } = useAuth();
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!mediaPath) return; if (!messageId) return;
const getUrl = async () => {
try {
// Handle legacy full URLs
let path = mediaPath;
if (path.startsWith('http')) {
const parts = path.split('/chat-media/');
if (parts.length > 1) {
path = decodeURIComponent(parts[1]);
}
}
const { data, error } = await supabase.storage if (!isSuperAdmin) {
.from('chat-media') setError('Decryption requires super admin');
.createSignedUrl(path, 3600);
if (error) {
console.error('Error creating signed URL for video:', error);
setError(error.message);
return; return;
} }
if (data?.signedUrl) { if (!session?.access_token) {
setUrl(data.signedUrl); setError('Not authenticated');
return;
} }
let cancelled = false;
let objectUrl: string | null = null;
const load = async () => {
try {
setError(null);
setUrl(null);
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
apikey: supabaseConfig.anonKey,
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ messageId }),
});
if (!res.ok) {
const raw = await res.text().catch(() => '');
let message = raw;
try {
const parsed = JSON.parse(raw) as { error?: unknown };
if (typeof parsed?.error === 'string') message = parsed.error;
} catch {
// ignore
}
throw new Error(message || `Failed to load media (${res.status})`);
}
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob);
if (cancelled) return;
setUrl(objectUrl);
} catch (err) { } catch (err) {
console.error('Unexpected error loading video:', err); if (cancelled) return;
setError('Failed to load'); setError(err instanceof Error ? err.message : 'Failed to load');
} }
}; };
getUrl();
}, [mediaPath]); load();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [messageId, isSuperAdmin, session?.access_token]);
if (error) { if (error) {
return ( return (
@@ -191,6 +253,77 @@ function ChatVideo({ mediaPath }: { mediaPath: string }) {
} }
function DecryptedMessageText({ message }: { message: Message }) {
const { isSuperAdmin } = useAuth();
const [text, setText] = useState<string | null>(null);
const [isDecrypting, setIsDecrypting] = useState(false);
useEffect(() => {
const version = message.version ?? 1;
// v1: legacy plaintext
if (version !== 2) {
setText(message.content ?? null);
setIsDecrypting(false);
return;
}
// v2: encrypted
if (!message.encrypted_content) {
setText(null);
setIsDecrypting(false);
return;
}
if (!isSuperAdmin) {
setText('[Encrypted]');
setIsDecrypting(false);
return;
}
let cancelled = false;
const decrypt = async () => {
setIsDecrypting(true);
try {
const { data, error } = await supabase.functions.invoke('admin-decrypt-message', {
body: { messageId: message.id },
});
if (cancelled) return;
if (error) {
setText('[Unable to decrypt]');
return;
}
setText((data as { content?: string | null } | null)?.content ?? null);
} catch (err) {
if (cancelled) return;
setText('[Unable to decrypt]');
} finally {
if (!cancelled) setIsDecrypting(false);
}
};
decrypt();
return () => {
cancelled = true;
};
}, [isSuperAdmin, message.content, message.encrypted_content, message.id, message.version]);
const shouldRender = Boolean(message.content || message.encrypted_content);
if (!shouldRender) return null;
return (
<p className="text-sm whitespace-pre-wrap">
{isDecrypting ? 'Loading...' : (text ?? '')}
</p>
);
}
export function MatchChatPage() { export function MatchChatPage() {
const { userId, matchId } = useParams<{ userId: string; matchId: string }>(); const { userId, matchId } = useParams<{ userId: string; matchId: string }>();
const [user, setUser] = useState<Profile | null>(null); const [user, setUser] = useState<Profile | null>(null);
@@ -405,16 +538,14 @@ export function MatchChatPage() {
: "bg-muted rounded-tl-sm" : "bg-muted rounded-tl-sm"
)} )}
> >
{message.content && ( <DecryptedMessageText message={message} />
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
)}
{message.media_path && ( {message.media_path && (
<div className="mt-2"> <div className="mt-2">
<div className="relative group"> <div className="relative group">
{message.media_type === 'video' ? ( {message.media_type === 'video' ? (
<ChatVideo mediaPath={message.media_path} /> <ChatVideo messageId={message.id} />
) : ( ) : (
<ChatImage mediaPath={message.media_path} /> <ChatImage messageId={message.id} />
)} )}
{message.media_viewed_at && ( {message.media_viewed_at && (
<Badge <Badge
+172 -529
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { supabase } from '@/config'; import { supabase, supabaseConfig } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
@@ -37,12 +37,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, HardDrive, ExternalLink, Target, User, Eye, EyeOff, CheckCircle } from 'lucide-react'; import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship'; type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say'; type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
// ... (interfaces and constants are the same)
interface Profile { interface Profile {
id: string; id: string;
name: string | null; name: string | null;
@@ -102,18 +104,6 @@ interface Match {
message_count?: number; message_count?: number;
} }
interface MediaFile {
id: string;
name: string;
bucket_id: string;
created_at: string | null;
metadata: {
size: number;
mimetype: string;
};
signedUrl?: string;
}
const answerIcons = { const answerIcons = {
yes: <ThumbsUp className="h-4 w-4 text-green-500" />, yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
no: <ThumbsDown className="h-4 w-4 text-red-500" />, no: <ThumbsDown className="h-4 w-4 text-red-500" />,
@@ -126,12 +116,117 @@ const matchTypeLabels = {
maybe_maybe: 'Both Maybe', maybe_maybe: 'Both Maybe',
}; };
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'; function AdminDecryptedImage({ messageId, alt }: { messageId: string; alt: string }) {
const k = 1024; const { session, isSuperAdmin } = useAuth();
const sizes = ['B', 'KB', 'MB', 'GB']; const [url, setUrl] = useState<string | null>(null);
const i = Math.floor(Math.log(bytes) / Math.log(k)); const [error, setError] = useState<string | null>(null);
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
useEffect(() => {
if (!messageId) return;
if (!isSuperAdmin) {
setError("Super admin required");
return;
}
if (!session?.access_token) {
setError("Not authenticated");
return;
}
let cancelled = false;
let objectUrl: string | null = null;
const load = async () => {
try {
setError(null);
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
body: JSON.stringify({ messageId }),
});
if (!res.ok) {
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
throw new Error(message);
}
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob);
if (!cancelled) setUrl(objectUrl);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load');
}
};
load();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [isSuperAdmin, messageId, session?.access_token]);
if (error) {
return <div className="w-full h-full flex items-center justify-center text-center p-2"><Image className="h-8 w-8 text-muted-foreground mx-auto" /><div className="text-[10px] text-red-500 mt-1 line-clamp-2" title={error}>{error}</div></div>;
}
if (!url) {
return <div className="w-full h-full flex items-center justify-center"><Skeleton className="h-16 w-16 rounded-md" /></div>;
}
return <img src={url} alt={alt} className="w-full h-full object-cover cursor-pointer" onClick={() => window.open(url, '_blank')} />;
}
function AdminDecryptedVideo({ messageId }: { messageId: string }) {
const { session, isSuperAdmin } = useAuth();
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!messageId) return;
if (!isSuperAdmin) {
setError("Super admin required");
return;
}
if (!session?.access_token) {
setError("Not authenticated");
return;
}
let cancelled = false;
let objectUrl: string | null = null;
const load = async () => {
try {
setError(null);
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
body: JSON.stringify({ messageId }),
});
if (!res.ok) {
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
throw new Error(message);
}
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob);
if (!cancelled) setUrl(objectUrl);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load');
}
};
load();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [isSuperAdmin, messageId, session?.access_token]);
if (error) {
return <div className="w-full h-full flex items-center justify-center text-center p-2"><VideoIcon className="h-8 w-8 text-muted-foreground mx-auto" /><div className="text-[10px] text-red-500 mt-1 line-clamp-2" title={error}>{error}</div></div>;
}
if (!url) {
return <div className="w-full h-full flex items-center justify-center"><Skeleton className="h-16 w-16 rounded-md" /></div>;
}
return <video src={url} controls className="w-full h-full object-cover" preload="metadata" />;
} }
export function UserDetailPage() { export function UserDetailPage() {
@@ -141,8 +236,7 @@ export function UserDetailPage() {
const [partner, setPartner] = useState<Partner | null>(null); const [partner, setPartner] = useState<Partner | null>(null);
const [responses, setResponses] = useState<Response[]>([]); const [responses, setResponses] = useState<Response[]>([]);
const [matches, setMatches] = useState<Match[]>([]); const [matches, setMatches] = useState<Match[]>([]);
const [media, setMedia] = useState<MediaFile[]>([]); const [mediaMessages, setMediaMessages] = useState<any[]>([]);
const [totalStorage, setTotalStorage] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES); const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
@@ -158,11 +252,9 @@ export function UserDetailPage() {
const handleUpgrade = async () => { const handleUpgrade = async () => {
if (!profile || !userId) return; if (!profile || !userId) return;
setUpgrading(true); setUpgrading(true);
try { try {
let expiresAt = null; let expiresAt = null;
const now = new Date(); const now = new Date();
if (expiryType === '1_month') { if (expiryType === '1_month') {
const date = new Date(now); const date = new Date(now);
date.setMonth(date.getMonth() + 1); date.setMonth(date.getMonth() + 1);
@@ -172,7 +264,6 @@ export function UserDetailPage() {
date.setFullYear(date.getFullYear() + 1); date.setFullYear(date.getFullYear() + 1);
expiresAt = date.toISOString(); expiresAt = date.toISOString();
} else if (expiryType === 'custom' && customDate) { } else if (expiryType === 'custom' && customDate) {
// Ensure custom date is in the future
const date = new Date(customDate); const date = new Date(customDate);
if (date <= now) { if (date <= now) {
alert('Expiry date must be in the future'); alert('Expiry date must be in the future');
@@ -181,7 +272,6 @@ export function UserDetailPage() {
} }
expiresAt = date.toISOString(); expiresAt = date.toISOString();
} }
const { error } = await auditedSupabase.insert('subscriptions', { const { error } = await auditedSupabase.insert('subscriptions', {
user_id: userId, user_id: userId,
revenuecat_app_user_id: 'admin_grant', revenuecat_app_user_id: 'admin_grant',
@@ -191,24 +281,10 @@ export function UserDetailPage() {
purchased_at: new Date().toISOString(), purchased_at: new Date().toISOString(),
expires_at: expiresAt, expires_at: expiresAt,
}); });
if (error) throw error; if (error) throw error;
const { data: updatedProfile } = await supabase.from('profiles').select('*').eq('id', userId).single();
// Refresh profile to get updated is_premium status if (updatedProfile) setProfile(updatedProfile);
// The trigger sync_premium_on_subscription_change will update the profile
// We might need a small delay or just wait for the select
const { data: updatedProfile } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
if (updatedProfile) {
setProfile(updatedProfile);
}
setUpgradeOpen(false); setUpgradeOpen(false);
// Optionally add toast here if available in context
alert('User upgraded to premium successfully!'); alert('User upgraded to premium successfully!');
} catch (err) { } catch (err) {
console.error('Failed to upgrade:', err); console.error('Failed to upgrade:', err);
@@ -220,101 +296,43 @@ export function UserDetailPage() {
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
if (!userId) return; if (!userId) return;
setLoading(true); setLoading(true);
try { try {
// Fetch profile const { data: profileData } = await supabase.from('profiles').select('*').eq('id', userId).single();
const { data: profileData } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
setProfile(profileData); setProfile(profileData);
// Fetch partner if coupled
if (profileData?.couple_id) { if (profileData?.couple_id) {
const { data: partnerData } = await supabase const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
.from('profiles')
.select('id, name')
.eq('couple_id', profileData.couple_id)
.neq('id', userId)
.single();
setPartner(partnerData); setPartner(partnerData);
} else {
setPartner(null);
} }
// Fetch responses with question and pack info const { data: responseData } = await supabase.from('responses').select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))').eq('user_id', userId).order('created_at', { ascending: false }).limit(50);
const { data: responseData } = await supabase
.from('responses')
.select(`
id, answer, created_at,
question:questions (
id, text,
pack:question_packs (name)
)
`)
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(50);
setResponses((responseData || []) as unknown as Response[]); setResponses((responseData || []) as unknown as Response[]);
// Fetch matches if coupled
if (profileData?.couple_id) { if (profileData?.couple_id) {
const { data: matchData } = await supabase const { data: matchData } = await supabase.from('matches').select('id, match_type, is_new, created_at, question:questions(id, text)').eq('couple_id', profileData.couple_id).order('created_at', { ascending: false }).limit(50);
.from('matches')
.select(`
id, match_type, is_new, created_at,
question:questions (id, text)
`)
.eq('couple_id', profileData.couple_id)
.order('created_at', { ascending: false })
.limit(50);
// Get message counts for each match
const matchIds = (matchData || []).map(m => m.id); const matchIds = (matchData || []).map(m => m.id);
const { data: messages } = await supabase if (matchIds.length > 0) {
.from('messages') const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
.select('match_id')
.in('match_id', matchIds);
const messageCounts: Record<string, number> = {}; const messageCounts: Record<string, number> = {};
messages?.forEach(m => { messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
}); const { data: mediaMessagesData, error: mediaError } = await supabase.from('messages').select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id').in('match_id', matchIds).not('media_path', 'is', null).order('created_at', { ascending: false });
if (mediaError) {
setMatches( console.error("Error fetching media messages:", mediaError);
(matchData || []).map(m => ({ setMediaMessages([]);
...m, } else {
message_count: messageCounts[m.id] || 0, setMediaMessages(mediaMessagesData || []);
})) as unknown as Match[]
);
} }
} else {
// Fetch media files uploaded by this user setMatches([]);
const { data: mediaData } = await supabase setMediaMessages([]);
.rpc('get_user_media_files', { user_id: userId }); }
} else {
if (mediaData && mediaData.length > 0) { setMatches([]);
// Calculate total storage setMediaMessages([]);
const total = mediaData.reduce((sum: number, file: MediaFile) =>
sum + (file.metadata?.size || 0), 0);
setTotalStorage(total);
// Get signed URLs for each file
const mediaWithUrls = await Promise.all(
mediaData.map(async (file: MediaFile) => {
const { data: signedData } = await supabase.storage
.from('chat-media')
.createSignedUrl(file.name, 3600); // 1 hour expiry
return {
...file,
signedUrl: signedData?.signedUrl,
};
})
);
setMedia(mediaWithUrls);
} }
} catch (error) { } catch (error) {
console.error('Failed to load user data:', error); console.error('Failed to load user data:', error);
@@ -323,431 +341,56 @@ export function UserDetailPage() {
} }
}, [userId]); }, [userId]);
// Real-time subscription for this user's profile const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) });
const { status: profileStatus } = useRealtimeSubscription<Profile>({ const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData });
table: 'profiles',
filter: userId ? `id=eq.${userId}` : undefined,
enabled: !!userId,
onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => {
setProfile(updated);
}, []),
});
// Real-time subscription for this user's responses useEffect(() => { fetchData(); }, [fetchData]);
const { status: responsesStatus } = useRealtimeSubscription<Response>({
table: 'responses',
filter: userId ? `user_id=eq.${userId}` : undefined,
enabled: !!userId,
onInsert: fetchData, // Refetch to get question/pack relations
onDelete: fetchData,
});
useEffect(() => { if (loading) return <div className="space-y-6"><Skeleton className="h-32" /><Skeleton className="h-96" /></div>;
fetchData(); if (!profile) return <div className="flex flex-col items-center justify-center py-12"><p className="text-lg font-medium">User not found</p><Link to="/users"><Button variant="link">Back to users</Button></Link></div>;
}, [fetchData]);
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-32" />
<Skeleton className="h-96" />
</div>
);
}
if (!profile) {
return (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-lg font-medium">User not found</p>
<Link to="/users">
<Button variant="link">Back to users</Button>
</Link>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Profile Header */} {/* Profile Header */}
<Card> <Card><CardContent className="pt-6"><div className="flex items-start gap-6">
<CardContent className="pt-6"> <Avatar className="h-20 w-20"><AvatarImage src={profile.avatar_url || undefined} /><AvatarFallback className="text-2xl">{profile.name?.charAt(0).toUpperCase() || 'U'}</AvatarFallback></Avatar>
<div className="flex items-start gap-6">
<Avatar className="h-20 w-20">
<AvatarImage src={profile.avatar_url || undefined} />
<AvatarFallback className="text-2xl">
{profile.name?.charAt(0).toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold"> <h1 className="text-2xl font-bold">{profile.name || 'Unnamed User'}</h1>
{profile.name || 'Unnamed User'} <RealtimeStatusIndicator status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus} showLabel />
</h1> {profile.is_premium && <Badge className="bg-amber-500"><Crown className="h-3 w-3 mr-1" />Premium</Badge>}
<RealtimeStatusIndicator {!profile.is_premium && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>}
status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus}
showLabel
/>
{profile.is_premium && (
<Badge className="bg-amber-500">
<Crown className="h-3 w-3 mr-1" />
Premium
</Badge>
)}
{!profile.is_premium && (
<Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="ml-2 gap-1 h-7">
<Gift className="h-3.5 w-3.5" />
Gift Premium
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Gift Premium Access</DialogTitle>
<DialogDescription>
Manually upgrade this user to premium status.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="expiry-type">Duration</Label>
<Select value={expiryType} onValueChange={setExpiryType}>
<SelectTrigger id="expiry-type">
<SelectValue placeholder="Select duration" />
</SelectTrigger>
<SelectContent>
<SelectItem value="forever">Lifetime (No Expiry)</SelectItem>
<SelectItem value="1_month">1 Month</SelectItem>
<SelectItem value="1_year">1 Year</SelectItem>
<SelectItem value="custom">Custom Date</SelectItem>
</SelectContent>
</Select>
</div> </div>
<p className="text-muted-foreground mb-4">{profile.email || 'No email'}</p>
{expiryType === 'custom' && (
<div className="grid gap-2">
<Label htmlFor="custom-date">Expiry Date</Label>
<Input
id="custom-date"
type="datetime-local"
value={customDate}
onChange={(e) => setCustomDate(e.target.value)}
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUpgradeOpen(false)}>
Cancel
</Button>
<Button onClick={handleUpgrade} disabled={upgrading}>
{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
<p className="text-muted-foreground mb-4">
{profile.email || 'No email'}
</p>
<div className="flex gap-6 text-sm"> <div className="flex gap-6 text-sm">
<div> <div><span className="text-muted-foreground">Joined: </span><span>{profile.created_at ? format(new Date(profile.created_at), 'MMMM d, yyyy') : '—'}</span></div>
<span className="text-muted-foreground">Joined: </span> {partner ? <div className="flex items-center gap-1"><Users className="h-4 w-4 text-muted-foreground" /><span className="text-muted-foreground">Paired with: </span><Link to={`/users/${partner.id}`} className="text-primary hover:underline">{partner.name || 'Unnamed Partner'}</Link></div> : <div className="text-muted-foreground">Not paired</div>}
<span>
{profile.created_at
? format(new Date(profile.created_at), 'MMMM d, yyyy')
: '—'}
</span>
</div>
{partner ? (
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Paired with: </span>
<Link to={`/users/${partner.id}`} className="text-primary hover:underline">
{partner.name || 'Unnamed Partner'}
</Link>
</div>
) : (
<div className="text-muted-foreground">Not paired</div>
)}
</div> </div>
</div> </div>
</div> </div></CardContent></Card>
</CardContent>
</Card>
{/* Onboarding Details */} {/* Onboarding Details */}
<Card> <Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<CardHeader className="pb-3"> <div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
<CardTitle className="text-lg flex items-center gap-2"> <div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
<CheckCircle className="h-5 w-5 text-primary" /> <div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
Onboarding Details <div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
</CardTitle> </div></CardContent></Card>
<CardDescription>
Information collected during user onboarding
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* Gender */}
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900">
<User className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Gender</p>
<p className="font-medium">
{profile.gender ? genderLabels[profile.gender] : 'Not set'}
</p>
</div>
</div>
{/* Usage Reason */}
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
<Target className="h-4 w-4 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Why using Sauci</p>
<p className="font-medium">
{profile.usage_reason
? usageReasonLabels[profile.usage_reason]?.label
: 'Not set'}
</p>
</div>
</div>
{/* Explicit Content */}
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>
{profile.show_explicit_content ? (
<Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" />
) : (
<EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />
)}
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Explicit Content</p>
<p className="font-medium">
{profile.show_explicit_content === null
? 'Not set'
: profile.show_explicit_content
? 'Enabled'
: 'Disabled'}
</p>
</div>
</div>
{/* Onboarding Status */}
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}>
<CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Onboarding</p>
<p className="font-medium">
{profile.onboarding_completed ? 'Completed' : 'Not completed'}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Tabs */} {/* Tabs */}
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}> <Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
<TabsList> <TabsList>
{canViewResponses && ( {canViewResponses && <TabsTrigger value="responses">Responses ({responses.length})</TabsTrigger>}
<TabsTrigger value="responses"> {canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matches.length})</TabsTrigger>}
Responses ({responses.length}) {canViewMedia && <TabsTrigger value="media">Media ({mediaMessages.length})</TabsTrigger>}
</TabsTrigger>
)}
{canViewMatches && (
<TabsTrigger value="matches" disabled={!profile.couple_id}>
Matches ({matches.length})
</TabsTrigger>
)}
{canViewMedia && (
<TabsTrigger value="media">
Media ({media.length})
</TabsTrigger>
)}
</TabsList> </TabsList>
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div>}</TabsContent>}
{canViewResponses && ( {canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div>}</TabsContent>}
<TabsContent value="responses" className="mt-4"> {canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
{responses.length === 0 ? ( {message.media_type === 'video' ? (
<Card className="flex flex-col items-center justify-center py-12"> <AdminDecryptedVideo messageId={message.id} />
<p className="text-muted-foreground">No responses yet</p>
</Card>
) : ( ) : (
<div className="rounded-md border"> <AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
<Table>
<TableHeader>
<TableRow>
<TableHead>Question</TableHead>
<TableHead>Pack</TableHead>
<TableHead className="w-24">Answer</TableHead>
<TableHead className="w-32">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{responses.map((r) => (
<TableRow key={r.id}>
<TableCell className="max-w-md">
<span className="line-clamp-2">{r.question.text}</span>
</TableCell>
<TableCell className="text-muted-foreground">
{r.question.pack?.name || '—'}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{answerIcons[r.answer]}
<span className="capitalize">{r.answer}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.created_at
? format(new Date(r.created_at), 'MMM d, yyyy')
: '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
)}
{canViewMatches && (
<TabsContent value="matches" className="mt-4">
{matches.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">No matches yet</p>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{matches.map((match) => (
<Card key={match.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<Badge variant="secondary">
{matchTypeLabels[match.match_type]}
</Badge>
{match.is_new && (
<Badge variant="default" className="text-xs">New</Badge>
)}
</div>
<CardDescription className="line-clamp-2 mt-2">
{match.question?.text || 'Unknown question'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<MessageCircle className="h-4 w-4" />
{match.message_count} message{match.message_count !== 1 ? 's' : ''}
</div>
{canViewChats && (
<Link to={`/users/${userId}/matches/${match.id}`}>
<Button variant="ghost" size="sm">
View Chat
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</Link>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
)}
{canViewMedia && (
<TabsContent value="media" className="mt-4">
{media.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<Image className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">No media uploaded</p>
</Card>
) : (
<div className="space-y-4">
{/* Storage Summary */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<HardDrive className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">Total Storage Used</p>
<p className="text-2xl font-bold">{formatBytes(totalStorage)}</p>
</div>
<div className="ml-auto text-sm text-muted-foreground">
{media.length} file{media.length !== 1 ? 's' : ''}
</div>
</div>
</CardContent>
</Card>
{/* Media Grid */}
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{media.map((file) => (
<Card key={file.id} className="overflow-hidden">
<div className="aspect-square bg-muted relative">
{file.signedUrl ? (
<img
src={file.signedUrl}
alt={file.name}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Image className="h-8 w-8 text-muted-foreground" />
</div>
)}
</div>
<CardContent className="p-3">
<div className="flex items-center justify-between text-sm">
<div className="truncate text-muted-foreground">
{formatBytes(file.metadata?.size || 0)}
</div>
{file.signedUrl && (
<a
href={file.signedUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
{file.created_at
? format(new Date(file.created_at), 'MMM d, yyyy')
: '—'}
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
</TabsContent>
)} )}
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div></div>}</TabsContent>}
</Tabs> </Tabs>
</div> </div>
); );
@@ -10,7 +10,6 @@
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="com.android.vending.BILLING"/> <uses-permission android:name="com.android.vending.BILLING"/>
<uses-permission android:name="com.android.vending.BILLING"/>
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
+58 -3
View File
@@ -5,6 +5,8 @@ import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore } from "../../src/store"; import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore } from "../../src/store";
import { useDecryptedMessage } from "../../src/hooks";
import type { KeysMetadata } from "../../src/lib/encryption";
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme"; import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
import { isBiometricEnabled } from "../../src/lib/biometricAuth"; import { isBiometricEnabled } from "../../src/lib/biometricAuth";
@@ -16,6 +18,61 @@ import type { Database } from "../../src/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"]; type Message = Database["public"]["Tables"]["messages"]["Row"];
function MessageToastPreview({
message,
currentUserId,
}: {
message: Message;
currentUserId: string;
}) {
if (message.media_path) {
const fallback = message.media_type === 'video' ? 'Sent a video' : 'Sent an image';
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
{fallback}
</Text>
);
}
const version = message.version ?? 1;
const isV2 = version === 2;
const { content, isDecrypting, error } = useDecryptedMessage({
message: {
id: message.id,
content: message.content,
version,
encrypted_content: message.encrypted_content,
encryption_iv: message.encryption_iv,
keys_metadata: message.keys_metadata as unknown as KeysMetadata | null,
user_id: message.user_id,
},
currentUserId,
});
if (isDecrypting) {
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
Loading...
</Text>
);
}
if (error) {
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
Failed to load message
</Text>
);
}
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
{content ?? (!isV2 ? message.content : null) ?? 'New message'}
</Text>
);
}
function TabBarBackground() { function TabBarBackground() {
// Only use BlurView on iOS - Android experimental blur causes white overlay artifacts // Only use BlurView on iOS - Android experimental blur causes white overlay artifacts
// The solid rgba background in tabBarStyle provides the visual effect on Android // The solid rgba background in tabBarStyle provides the visual effect on Android
@@ -820,9 +877,7 @@ export default function AppLayout() {
<Text style={styles.messageToastTitle} numberOfLines={1}> <Text style={styles.messageToastTitle} numberOfLines={1}>
New message New message
</Text> </Text>
<Text style={styles.messageToastBody} numberOfLines={1}> <MessageToastPreview message={lastMessage} currentUserId={user?.id ?? ''} />
{lastMessage.content || "Sent an image"}
</Text>
</View> </View>
<Pressable onPress={dismissMessageToast} hitSlop={8}> <Pressable onPress={dismissMessageToast} hitSlop={8}>
<Ionicons name="close" size={20} color={colors.textTertiary} /> <Ionicons name="close" size={20} color={colors.textTertiary} />
+31 -2
View File
@@ -47,8 +47,15 @@ function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPre
<View style={styles.packContent}> <View style={styles.packContent}>
<View style={styles.packTitleRow}> <View style={styles.packTitleRow}>
<Text style={styles.packName} numberOfLines={1}>{pack.name}</Text> <Text style={styles.packName} numberOfLines={1}>{pack.name}</Text>
{isPremiumLocked && ( {pack.is_premium && (
<Ionicons name="lock-closed" size={14} color={colors.premium.gold} /> <View style={[styles.premiumBadge, isPremiumLocked && styles.premiumBadgeLocked]}>
<Ionicons
name={isPremiumLocked ? "lock-closed" : "sparkles"}
size={12}
color={colors.premium.gold}
/>
<Text style={styles.premiumBadgeText}>PRO</Text>
</View>
)} )}
</View> </View>
<Text style={styles.packMeta}> <Text style={styles.packMeta}>
@@ -635,6 +642,28 @@ const styles = StyleSheet.create({
...typography.body, ...typography.body,
color: colors.text, color: colors.text,
fontWeight: "500", fontWeight: "500",
flexShrink: 1,
},
premiumBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: radius.full,
backgroundColor: "rgba(212, 175, 55, 0.12)",
borderWidth: 1,
borderColor: "rgba(212, 175, 55, 0.25)",
},
premiumBadgeLocked: {
backgroundColor: "rgba(212, 175, 55, 0.18)",
borderColor: "rgba(212, 175, 55, 0.35)",
},
premiumBadgeText: {
...typography.caption2,
color: colors.premium.gold,
fontWeight: "700",
letterSpacing: 0.6,
}, },
packMeta: { packMeta: {
...typography.caption1, ...typography.caption1,
+4 -1
View File
@@ -1,5 +1,6 @@
// Web polyfills must be imported FIRST before any other React Native imports // Polyfills must be imported FIRST before any other React Native imports
import "../src/polyfills/web"; import "../src/polyfills/web";
import "../src/polyfills/crypto";
// Initialize Sentry early to capture all errors // Initialize Sentry early to capture all errors
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry"; import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
@@ -47,6 +48,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import { useAuthStore } from "../src/store"; import { useAuthStore } from "../src/store";
import { supabase } from "../src/lib/supabase"; import { supabase } from "../src/lib/supabase";
import { EncryptionKeyInitializer } from "../src/components/EncryptionKeyInitializer";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -211,6 +213,7 @@ export default function RootLayout() {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="light" /> <StatusBar style="light" />
<EncryptionKeyInitializer />
<Stack <Stack
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
+1 -1
View File
@@ -467,7 +467,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.lg, marginBottom: spacing.lg,
}, },
heroInner: { heroInner: {
paddingTop: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT + spacing.md, paddingTop: STATUS_BAR_HEIGHT + spacing.md,
paddingBottom: spacing.lg, paddingBottom: spacing.lg,
minHeight: HERO_HEIGHT, minHeight: HERO_HEIGHT,
}, },
+37
View File
@@ -271,6 +271,9 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoLocalAuthentication (15.0.2): - ExpoLocalAuthentication (15.0.2):
- ExpoModulesCore - ExpoModulesCore
- ExpoMediaLibrary (17.0.6):
- ExpoModulesCore
- React-Core
- ExpoModulesCore (2.2.3): - ExpoModulesCore (2.2.3):
- DoubleConversion - DoubleConversion
- glog - glog
@@ -328,6 +331,7 @@ PODS:
- libavif/core - libavif/core
- libdav1d (>= 0.6.0) - libdav1d (>= 0.6.0)
- libdav1d (1.2.0) - libdav1d (1.2.0)
- OpenSSL-Universal (3.3.3001)
- PromisesObjC (2.4.0) - PromisesObjC (2.4.0)
- PurchasesHybridCommon (17.25.0): - PurchasesHybridCommon (17.25.0):
- RevenueCat (= 5.51.1) - RevenueCat (= 5.51.1)
@@ -1630,6 +1634,29 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Yoga - Yoga
- react-native-quick-crypto (0.7.17):
- DoubleConversion
- glog
- hermes-engine
- OpenSSL-Universal
- RCT-Folly (= 2024.10.14.00)
- RCTRequired
- RCTTypeSafety
- React
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-safe-area-context (4.12.0): - react-native-safe-area-context (4.12.0):
- React-Core - React-Core
- React-nativeconfig (0.76.9) - React-nativeconfig (0.76.9)
@@ -2108,6 +2135,7 @@ DEPENDENCIES:
- ExpoLinearGradient (from `../../../node_modules/expo-linear-gradient/ios`) - ExpoLinearGradient (from `../../../node_modules/expo-linear-gradient/ios`)
- ExpoLinking (from `../../../node_modules/expo-linking/ios`) - ExpoLinking (from `../../../node_modules/expo-linking/ios`)
- ExpoLocalAuthentication (from `../../../node_modules/expo-local-authentication/ios`) - ExpoLocalAuthentication (from `../../../node_modules/expo-local-authentication/ios`)
- ExpoMediaLibrary (from `../../../node_modules/expo-media-library/ios`)
- ExpoModulesCore (from `../../../node_modules/expo-modules-core`) - ExpoModulesCore (from `../../../node_modules/expo-modules-core`)
- ExpoSecureStore (from `../../../node_modules/expo-secure-store/ios`) - ExpoSecureStore (from `../../../node_modules/expo-secure-store/ios`)
- EXUpdatesInterface (from `../../../node_modules/expo-updates-interface/ios`) - EXUpdatesInterface (from `../../../node_modules/expo-updates-interface/ios`)
@@ -2148,6 +2176,7 @@ DEPENDENCIES:
- React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`) - React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`)
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- react-native-compressor (from `../../../node_modules/react-native-compressor`) - react-native-compressor (from `../../../node_modules/react-native-compressor`)
- react-native-quick-crypto (from `../../../node_modules/react-native-quick-crypto`)
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`) - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
- React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`) - React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
@@ -2195,6 +2224,7 @@ SPEC REPOS:
- GTMSessionFetcher - GTMSessionFetcher
- libavif - libavif
- libdav1d - libdav1d
- OpenSSL-Universal
- PromisesObjC - PromisesObjC
- PurchasesHybridCommon - PurchasesHybridCommon
- RevenueCat - RevenueCat
@@ -2263,6 +2293,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/expo-linking/ios" :path: "../../../node_modules/expo-linking/ios"
ExpoLocalAuthentication: ExpoLocalAuthentication:
:path: "../../../node_modules/expo-local-authentication/ios" :path: "../../../node_modules/expo-local-authentication/ios"
ExpoMediaLibrary:
:path: "../../../node_modules/expo-media-library/ios"
ExpoModulesCore: ExpoModulesCore:
:path: "../../../node_modules/expo-modules-core" :path: "../../../node_modules/expo-modules-core"
ExpoSecureStore: ExpoSecureStore:
@@ -2340,6 +2372,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-compressor: react-native-compressor:
:path: "../../../node_modules/react-native-compressor" :path: "../../../node_modules/react-native-compressor"
react-native-quick-crypto:
:path: "../../../node_modules/react-native-quick-crypto"
react-native-safe-area-context: react-native-safe-area-context:
:path: "../../../node_modules/react-native-safe-area-context" :path: "../../../node_modules/react-native-safe-area-context"
React-nativeconfig: React-nativeconfig:
@@ -2445,6 +2479,7 @@ SPEC CHECKSUMS:
ExpoLinearGradient: 35ebd83b16f80b3add053a2fd68cc328ed927f60 ExpoLinearGradient: 35ebd83b16f80b3add053a2fd68cc328ed927f60
ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402 ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402
ExpoLocalAuthentication: eb2be9c7bcdc68e9434d4be4bb5aa5cf7943e816 ExpoLocalAuthentication: eb2be9c7bcdc68e9434d4be4bb5aa5cf7943e816
ExpoMediaLibrary: 2709fc2e4fcf87d28767714abcc603c4bec228c6
ExpoModulesCore: 725faec070d590810d2ea5983d9f78f7cf6a38ec ExpoModulesCore: 725faec070d590810d2ea5983d9f78f7cf6a38ec
ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316 ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316
EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db
@@ -2459,6 +2494,7 @@ SPEC CHECKSUMS:
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11 hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428 PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17 RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17
@@ -2491,6 +2527,7 @@ SPEC CHECKSUMS:
React-Mapbuffer: 33546a3ebefbccb8770c33a1f8a5554fa96a54de React-Mapbuffer: 33546a3ebefbccb8770c33a1f8a5554fa96a54de
React-microtasksnativemodule: 5c3d795318c22ab8df55100e50b151384a4a60b3 React-microtasksnativemodule: 5c3d795318c22ab8df55100e50b151384a4a60b3
react-native-compressor: 2c3a41768bdd367f6e121f555dff9d3eda1810e5 react-native-compressor: 2c3a41768bdd367f6e121f555dff9d3eda1810e5
react-native-quick-crypto: 7bb26af9b3705dbb9277c09305104d37ede74916
react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448 react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448
React-nativeconfig: 8efdb1ef1e9158c77098a93085438f7e7b463678 React-nativeconfig: 8efdb1ef1e9158c77098a93085438f7e7b463678
React-NativeModulesApple: cebca2e5320a3d66e123cade23bd90a167ffce5e React-NativeModulesApple: cebca2e5320a3d66e123cade23bd90a167ffce5e
+52 -48
View File
@@ -3,37 +3,37 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 54; objectVersion = 46;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; }; 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
356786A828768878A952F4E5 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 9F524F031F5995D63DB94706 /* PrivacyInfo.xcprivacy */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
720501527E1546CCB1AFBCF5 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = F115714F0491496B8D41CDD9 /* noop-file.swift */; }; 7FA67D035683415E8F745B3C /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7459E7F6C9814324B0B1C2B8 /* noop-file.swift */; };
96905EF65AED1B983A6B3ABC /* libPods-Sauci.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */; }; 96905EF65AED1B983A6B3ABC /* libPods-Sauci.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
C1A16C113B7B1472578327D9 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 091FB6EEA51288ED888C8E3C /* PrivacyInfo.xcprivacy */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
091FB6EEA51288ED888C8E3C /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; }; 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; }; 13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; }; 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
37BFBB5C91964731BEAF4E57 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Sauci.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Sauci.a"; sourceTree = BUILT_PRODUCTS_DIR; };
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; }; 6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
7459E7F6C9814324B0B1C2B8 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; }; 7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
9F524F031F5995D63DB94706 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; };
B802546CC5A74898A9211A64 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F115714F0491496B8D41CDD9 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; }; FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -59,9 +59,9 @@
13B07FB61A68108700A75B9A /* Info.plist */, 13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* main.m */, 13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
F115714F0491496B8D41CDD9 /* noop-file.swift */, 7459E7F6C9814324B0B1C2B8 /* noop-file.swift */,
B802546CC5A74898A9211A64 /* Sauci-Bridging-Header.h */, 37BFBB5C91964731BEAF4E57 /* Sauci-Bridging-Header.h */,
9F524F031F5995D63DB94706 /* PrivacyInfo.xcprivacy */, 091FB6EEA51288ED888C8E3C /* PrivacyInfo.xcprivacy */,
); );
name = Sauci; name = Sauci;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -147,14 +147,14 @@
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
buildPhases = ( buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
E65B38D1E7CDAB3F0A2275AF /* [Expo] Configure project */, 19CD656710F28335C54F2FDE /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */, 13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */, 13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
B4C9C862821045DC99C4CEEE /* Upload Debug Symbols to Sentry */, 987394C7D2294ABDB4919B28 /* Upload Debug Symbols to Sentry */,
295448ADEAF428243104EA3C /* [CP] Embed Pods Frameworks */, AFEE2A0A2946A54F41B5668F /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
@@ -174,7 +174,9 @@
LastUpgradeCheck = 1130; LastUpgradeCheck = 1130;
TargetAttributes = { TargetAttributes = {
13B07F861A680F5B00A75B9A = { 13B07F861A680F5B00A75B9A = {
DevelopmentTeam = BXVPBATDZY;
LastSwiftMigration = 1250; LastSwiftMigration = 1250;
ProvisioningStyle = Automatic;
}; };
}; };
}; };
@@ -204,7 +206,7 @@
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
356786A828768878A952F4E5 /* PrivacyInfo.xcprivacy in Resources */, C1A16C113B7B1472578327D9 /* PrivacyInfo.xcprivacy in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -248,23 +250,24 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
295448ADEAF428243104EA3C /* [CP] Embed Pods Frameworks */ = { 19CD656710F28335C54F2FDE /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
inputFileListPaths = (
);
inputPaths = ( inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh", );
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", name = "[Expo] Configure project";
outputFileListPaths = (
); );
name = "[CP] Embed Pods Frameworks";
outputPaths = ( outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n"; shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n";
showEnvVarsInLog = 0;
}; };
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
@@ -279,6 +282,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GTMAppAuth/GTMAppAuth_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/GTMAppAuth/GTMAppAuth_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GTMSessionFetcher/GTMSessionFetcher_Core_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/GTMSessionFetcher/GTMSessionFetcher_Core_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle",
@@ -305,6 +309,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMAppAuth_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMAppAuth_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMSessionFetcher_Core_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMSessionFetcher_Core_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle",
@@ -328,7 +333,7 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
B4C9C862821045DC99C4CEEE /* Upload Debug Symbols to Sentry */ = { 987394C7D2294ABDB4919B28 /* Upload Debug Symbols to Sentry */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -342,24 +347,25 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`"; shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
}; };
E65B38D1E7CDAB3F0A2275AF /* [Expo] Configure project */ = { AFEE2A0A2946A54F41B5668F /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
inputFileListPaths = (
);
inputPaths = ( inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
); );
name = "[Expo] Configure project"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
);
outputPaths = ( outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n";
showEnvVarsInLog = 0;
}; };
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
@@ -371,7 +377,7 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */, 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */, 13B07FC11A68108700A75B9A /* main.m in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
720501527E1546CCB1AFBCF5 /* noop-file.swift in Sources */, 7FA67D035683415E8F745B3C /* noop-file.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -385,6 +391,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -394,10 +402,7 @@
); );
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
@@ -422,14 +427,13 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
@@ -494,14 +498,14 @@
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = "$(inherited) "; OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -550,13 +554,13 @@
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = "$(inherited) "; OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
USE_HERMES = true; USE_HERMES = true;
+1 -1
View File
@@ -2,7 +2,7 @@
module.exports = { module.exports = {
preset: 'jest-expo', preset: 'jest-expo',
clearMocks: true, clearMocks: true,
setupFiles: ['<rootDir>/node_modules/react-native-gesture-handler/jestSetup.js'], setupFiles: ['../../node_modules/react-native-gesture-handler/jestSetup.js'],
setupFilesAfterEnv: [ setupFilesAfterEnv: [
'@testing-library/jest-native/extend-expect', '@testing-library/jest-native/extend-expect',
'<rootDir>/src/test/setup.ts', '<rootDir>/src/test/setup.ts',
+7
View File
@@ -25,4 +25,11 @@ config.resolver.nodeModulesPaths = [
// of the workspace root, unless they are installed in the project root. // of the workspace root, unless they are installed in the project root.
config.resolver.disableHierarchicalLookup = true; config.resolver.disableHierarchicalLookup = true;
// 4. E2EE crypto polyfill: resolve 'crypto' to react-native-quick-crypto
// This enables Web Crypto API for encryption operations
config.resolver.extraNodeModules = {
...config.resolver.extraNodeModules,
crypto: require.resolve('react-native-quick-crypto'),
};
module.exports = config; module.exports = config;
+2
View File
@@ -44,6 +44,7 @@
"expo-linear-gradient": "~14.0.2", "expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.0", "expo-linking": "~7.0.0",
"expo-local-authentication": "~15.0.2", "expo-local-authentication": "~15.0.2",
"expo-media-library": "~17.0.6",
"expo-notifications": "~0.29.0", "expo-notifications": "~0.29.0",
"expo-router": "~4.0.0", "expo-router": "~4.0.0",
"expo-secure-store": "~14.0.0", "expo-secure-store": "~14.0.0",
@@ -55,6 +56,7 @@
"react-native-compressor": "^1.13.0", "react-native-compressor": "^1.13.0",
"react-native-gesture-handler": "~2.20.0", "react-native-gesture-handler": "~2.20.0",
"react-native-purchases": "^9.6.12", "react-native-purchases": "^9.6.12",
"react-native-quick-crypto": "^0.7.11",
"react-native-reanimated": "~3.16.0", "react-native-reanimated": "~3.16.0",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",
@@ -42,13 +42,28 @@ describe('useMessageSubscription', () => {
const fetchQuery = createThenableQuery({ data: [partnerMessage] }); const fetchQuery = createThenableQuery({ data: [partnerMessage] });
const updateQuery = createThenableQuery({ data: null }); const updateQuery = createThenableQuery({ data: null });
const deletionsQuery = createThenableQuery({ data: [] });
const fromMock = jest const fromMock = jest.fn((table: string) => {
.fn() if (table === 'message_deletions') return deletionsQuery;
.mockReturnValueOnce(fetchQuery) if (table === 'messages') {
.mockReturnValueOnce(updateQuery); // If it's the first call to messages, it's fetch, otherwise update.
// But simplified for the test logic:
return fetchQuery;
}
return createThenableQuery({ data: null });
});
(supabase as any).from = fromMock; // Use mockReturnValueOnce to handle the sequence:
// 1. message_deletions (select)
// 2. messages (select)
// 3. messages (update)
const fromMockSequence = jest.fn()
.mockReturnValueOnce(deletionsQuery) // message_deletions select
.mockReturnValueOnce(fetchQuery) // messages select
.mockReturnValueOnce(updateQuery); // messages update
(supabase as any).from = fromMockSequence;
const channelMock: any = { const channelMock: any = {
on: jest.fn(() => channelMock), on: jest.fn(() => channelMock),
@@ -63,7 +78,7 @@ describe('useMessageSubscription', () => {
await waitFor(() => expect(result.current.loading).toBe(false)); await waitFor(() => expect(result.current.loading).toBe(false));
expect(fromMock).toHaveBeenCalledWith('messages'); expect(fromMockSequence).toHaveBeenCalledWith('messages');
expect(updateQuery.update).toHaveBeenCalledWith( expect(updateQuery.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
delivered_at: expect.stringMatching(/^2024-01-01T00:00:00\./), delivered_at: expect.stringMatching(/^2024-01-01T00:00:00\./),
@@ -0,0 +1,194 @@
/**
* EncryptionKeyInitializer Component
*
* Handles automatic initialization and upload of E2EE encryption keys.
* Should be placed in the app layout to run after authentication.
*
* Uses global Zustand store for key state to ensure all components
* see the same keys at all times.
*/
import { useEffect, useRef, useState } from 'react';
import { Platform } from 'react-native';
import { useEncryptionKeys } from '../hooks';
import { useAuthStore } from '../store';
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
import { triggerKeyRotation } from '../lib/encryption';
import { LoadingOverlay } from './ui/LoadingOverlay';
import { colors } from '../theme';
/**
* Check if crypto is available (react-native-quick-crypto polyfill installed)
*/
function isCryptoAvailable(): boolean {
return typeof crypto !== 'undefined' && crypto.subtle !== undefined;
}
/**
* Component that initializes encryption keys after user authentication.
*
* Flow:
* 1. When user signs in, check if they have encryption keys
* 2. If not, generate new RSA key pair
* 3. Upload public key to Supabase profile
* 4. If user has a partner without keys, trigger re-encryption when partner generates keys
*/
export function EncryptionKeyInitializer(): JSX.Element | null {
const { initializeAndUploadKeys, isLoading: keysLoading, hasKeys, error } = useEncryptionKeys();
const user = useAuthStore((s) => s.user);
const couple = useAuthStore((s) => s.couple);
// Track if keys were regenerated this session (new device, reinstall, etc.)
const [wasRegenerated, setWasRegenerated] = useState(false);
const hasTriggeredRotation = useRef(false);
const hasLoggedCryptoWarning = useRef(false);
const isInitializing = useRef(false);
const hasInitialized = useRef(false);
// Show loading overlay when we are initializing keys (generating new ones takes time)
// or when rotation is in progress
const [showLoading, setShowLoading] = useState(false);
const [loadingStatus, setLoadingStatus] = useState('Securing your app...');
// Effect 1: Initialize encryption keys when user is authenticated
useEffect(() => {
// Skip on web - E2EE not supported
if (Platform.OS === 'web') return;
// Check if crypto is available (native modules rebuilt)
if (!isCryptoAvailable()) {
if (!hasLoggedCryptoWarning.current) {
console.warn('[E2EE Init] Crypto not available. Run: npx expo prebuild --clean');
hasLoggedCryptoWarning.current = true;
}
return;
}
// Skip if no user, already initialized, or currently initializing
if (!user?.id || hasInitialized.current || isInitializing.current) return;
// Wait for initial key loading to complete
if (keysLoading) return;
// Skip if keys already exist in global state
if (hasKeys) {
console.log('[E2EE Init] Keys already loaded in global state');
hasInitialized.current = true;
return;
}
// Skip if there was an error loading keys (avoid retry loop)
if (error) {
console.warn('[E2EE Init] Skipping key initialization due to previous error:', error.message);
return;
}
const initKeys = async () => {
try {
// Mark as initializing to prevent re-entry
isInitializing.current = true;
setShowLoading(true);
setLoadingStatus('Securing your app...');
console.log('[E2EE Init] Starting key initialization...');
// Initialize keys and upload in one operation
const result = await initializeAndUploadKeys();
console.log('[E2EE Init] Encryption keys initialized and uploaded');
// Track if keys were regenerated (for rotation decision)
if (result.wasRegenerated) {
console.log('[E2EE Init] Keys were regenerated this session');
setWasRegenerated(true);
}
hasInitialized.current = true;
} catch (err) {
console.error('[E2EE Init] Failed to initialize encryption keys:', err);
// Reset initialization flag so we can retry on next render
// But only if it's a transient error, not a crypto unavailable error
if (err instanceof Error && !err.message.includes('Crypto')) {
isInitializing.current = false;
}
setShowLoading(false);
}
};
initKeys();
}, [user?.id, keysLoading, hasKeys, error, initializeAndUploadKeys]);
// Effect 2: Trigger key rotation when keys were regenerated and couple is available
useEffect(() => {
// Skip on web or if crypto not available
if (Platform.OS === 'web' || !isCryptoAvailable()) return;
// Need user and couple to trigger rotation
if (!user?.id || !couple?.id) return;
// Only trigger once per session
if (hasTriggeredRotation.current) return;
// Wait for keys to be initialized in global state
if (!hasKeys) return;
// Only trigger rotation if keys were actually regenerated this session
// (new device, app reinstall, key corruption recovery, etc.)
if (!wasRegenerated) {
// If we're done initializing but not rotating, hide loading
if (showLoading) {
setShowLoading(false);
isInitializing.current = false;
}
console.log('[E2EE Init] Keys unchanged, skipping rotation');
return;
}
const runRotation = async () => {
try {
hasTriggeredRotation.current = true;
setLoadingStatus('Updating message security...');
// Trigger key rotation - re-wraps message keys for the new public key
console.log('[E2EE Init] Keys were regenerated, triggering key rotation...');
await triggerKeyRotation();
// Trigger re-encryption of pending messages (for messages where
// recipient had no key at send time)
await reencryptPendingMessages({ force: true });
console.log('[E2EE Init] Key rotation complete');
} catch (err) {
console.error('[E2EE Init] Failed to run key rotation:', err);
// Reset so we can retry
hasTriggeredRotation.current = false;
} finally {
setShowLoading(false);
isInitializing.current = false;
}
};
runRotation();
}, [user?.id, couple?.id, hasKeys, wasRegenerated, showLoading]);
// Reset initialization flags when user changes (sign out/sign in)
useEffect(() => {
if (!user?.id) {
hasInitialized.current = false;
setWasRegenerated(false);
isInitializing.current = false;
hasTriggeredRotation.current = false;
setShowLoading(false);
}
}, [user?.id]);
return (
<LoadingOverlay
visible={showLoading}
statusText={loadingStatus}
detailText="This helps keep your conversations private."
fullScreen={true}
spinnerColor={colors.primary}
/>
);
}
@@ -28,7 +28,7 @@ export interface ReadReceiptProps {
export function ReadReceipt({ export function ReadReceipt({
status, status,
size = 14, size = 14,
readColor = colors.premium.gold, readColor = colors.info,
unreadColor = 'rgba(255, 255, 255, 0.4)', unreadColor = 'rgba(255, 255, 255, 0.4)',
style, style,
}: ReadReceiptProps) { }: ReadReceiptProps) {
+102 -1
View File
@@ -11,10 +11,11 @@ import Animated, { FadeInDown, useAnimatedStyle } from "react-native-reanimated"
import { InputBar, ChatHeader, ChatMessages } from "./components"; import { InputBar, ChatHeader, ChatMessages } from "./components";
import { useMediaUpload } from "./hooks/useMediaUpload"; import { useMediaUpload } from "./hooks/useMediaUpload";
import { useMessageActions } from "./hooks/useMessageActions";
import { supabase } from "../../lib/supabase"; import { supabase } from "../../lib/supabase";
import { useAuthStore, useMessageStore } from "../../store"; import { useAuthStore, useMessageStore } from "../../store";
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription } from "../../hooks"; import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useEncryptedSend, useMediaSaver } from "../../hooks";
import { Database } from "../../types/supabase"; import { Database } from "../../types/supabase";
import { GradientBackground } from "../../components/ui"; import { GradientBackground } from "../../components/ui";
import { colors, gradients, spacing } from "../../theme"; import { colors, gradients, spacing } from "../../theme";
@@ -37,6 +38,7 @@ export const ChatScreen: React.FC = () => {
const [match, setMatch] = useState<any>(null); const [match, setMatch] = useState<any>(null);
const [inputText, setInputText] = useState(""); const [inputText, setInputText] = useState("");
const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id); const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id);
const { saveMedia, saving: mediaSaving } = useMediaSaver();
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null); const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true); const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true);
const [fullScreenVideo, setFullScreenVideo] = useState<string | null>(null); const [fullScreenVideo, setFullScreenVideo] = useState<string | null>(null);
@@ -67,6 +69,19 @@ export const ChatScreen: React.FC = () => {
onNewMessage: clearTypingIndicator, onNewMessage: clearTypingIndicator,
}); });
// E2EE encryption hook for sending messages
const { encryptMessage, isE2EEAvailable } = useEncryptedSend();
// Message actions hook for deletion
const { showDeleteOptions } = useMessageActions({ userId: user?.id });
// Handle long press on message to show delete options
const handleMessageLongPress = useCallback((message: Message, isMe: boolean) => {
// Don't show options for already deleted messages
if (message.deleted_at) return;
showDeleteOptions(message, isMe);
}, [showDeleteOptions]);
// Track active chat to prevent notifications for current chat // Track active chat to prevent notifications for current chat
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -121,6 +136,39 @@ export const ChatScreen: React.FC = () => {
const content = inputText.trim(); const content = inputText.trim();
setInputText(""); setInputText("");
try {
// Try to encrypt the message if E2EE is available
console.log(`[E2EE Send] isE2EEAvailable=${isE2EEAvailable}`);
const encryptedPayload = isE2EEAvailable ? await encryptMessage(content) : null;
console.log(`[E2EE Send] encryptedPayload=`, encryptedPayload ? {
version: encryptedPayload.version,
encrypted_content_length: encryptedPayload.encrypted_content.length,
encryption_iv_length: encryptedPayload.encryption_iv.length,
keys_metadata: encryptedPayload.keys_metadata,
} : null);
if (encryptedPayload) {
// Send encrypted message (v2)
const { error, data } = await supabase.from("messages").insert({
match_id: matchId,
user_id: user.id,
content: null,
version: encryptedPayload.version,
encrypted_content: encryptedPayload.encrypted_content,
encryption_iv: encryptedPayload.encryption_iv,
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
}).select();
console.log(`[E2EE Send] Insert result:`, { error, data });
if (error) {
Alert.alert("Error", "Failed to send message");
setInputText(content);
} else {
Events.messageSent();
}
} else {
// Fallback to plaintext (v1) if E2EE not available
const { error } = await supabase.from("messages").insert({ const { error } = await supabase.from("messages").insert({
match_id: matchId, match_id: matchId,
user_id: user.id, user_id: user.id,
@@ -133,6 +181,12 @@ export const ChatScreen: React.FC = () => {
} else { } else {
Events.messageSent(); Events.messageSent();
} }
}
} catch (err) {
console.error("Error sending message:", err);
Alert.alert("Error", "Failed to send message");
setInputText(content);
}
}; };
const handlePickMedia = async () => { const handlePickMedia = async () => {
@@ -223,6 +277,7 @@ export const ChatScreen: React.FC = () => {
onImagePress={setFullScreenImage} onImagePress={setFullScreenImage}
onVideoFullScreen={setFullScreenVideo} onVideoFullScreen={setFullScreenVideo}
revealMessage={revealMessage} revealMessage={revealMessage}
onMessageLongPress={handleMessageLongPress}
/> />
{/* Premium Input Bar */} {/* Premium Input Bar */}
@@ -260,6 +315,26 @@ export const ChatScreen: React.FC = () => {
onRequestClose={() => setFullScreenImage(null)} onRequestClose={() => setFullScreenImage(null)}
> >
<View style={styles.fullScreenOverlay}> <View style={styles.fullScreenOverlay}>
<TouchableOpacity
style={styles.fullScreenSaveButton}
onPress={() => fullScreenImage && saveMedia(fullScreenImage, 'image')}
activeOpacity={0.8}
disabled={mediaSaving}
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.fullScreenCloseGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{mediaSaving ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Ionicons name="download-outline" size={24} color={colors.text} />
)}
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.fullScreenCloseButton} style={styles.fullScreenCloseButton}
onPress={() => setFullScreenImage(null)} onPress={() => setFullScreenImage(null)}
@@ -298,6 +373,26 @@ export const ChatScreen: React.FC = () => {
onRequestClose={() => setFullScreenVideo(null)} onRequestClose={() => setFullScreenVideo(null)}
> >
<View style={styles.fullScreenOverlay}> <View style={styles.fullScreenOverlay}>
<TouchableOpacity
style={styles.fullScreenSaveButton}
onPress={() => fullScreenVideo && saveMedia(fullScreenVideo, 'video')}
activeOpacity={0.8}
disabled={mediaSaving}
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.fullScreenCloseGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{mediaSaving ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Ionicons name="download-outline" size={24} color={colors.text} />
)}
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.fullScreenCloseButton} style={styles.fullScreenCloseButton}
onPress={() => setFullScreenVideo(null)} onPress={() => setFullScreenVideo(null)}
@@ -365,6 +460,12 @@ const styles = StyleSheet.create({
right: 20, right: 20,
zIndex: 10, zIndex: 10,
}, },
fullScreenSaveButton: {
position: "absolute",
top: 50,
left: 20,
zIndex: 10,
},
fullScreenCloseGradient: { fullScreenCloseGradient: {
width: 44, width: 44,
height: 44, height: 44,
@@ -22,6 +22,8 @@ interface ChatMessagesProps {
onImagePress: (uri: string) => void; onImagePress: (uri: string) => void;
onVideoFullScreen: (uri: string) => void; onVideoFullScreen: (uri: string) => void;
revealMessage: (messageId: string) => void; revealMessage: (messageId: string) => void;
/** Called when a message bubble is long pressed */
onMessageLongPress: (message: Message, isMe: boolean) => void;
} }
const ACCENT = colors.premium.gold; const ACCENT = colors.premium.gold;
@@ -35,16 +37,22 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
onImagePress, onImagePress,
onVideoFullScreen, onVideoFullScreen,
revealMessage, revealMessage,
onMessageLongPress,
}) => { }) => {
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const renderMessage = ({ item, index }: { item: Message; index: number }) => { const renderMessage = ({ item, index }: { item: Message; index: number }) => {
const isMe = item.user_id === userId; const isMe = item.user_id === userId;
return ( return (
<MessageBubble isMe={isMe} index={index}> <MessageBubble
isMe={isMe}
index={index}
onLongPress={() => onMessageLongPress(item, isMe)}
>
<MessageContent <MessageContent
item={item} item={item}
isMe={isMe} isMe={isMe}
currentUserId={userId || ''}
revealMessage={revealMessage} revealMessage={revealMessage}
onImagePress={onImagePress} onImagePress={onImagePress}
onVideoFullScreen={onVideoFullScreen} onVideoFullScreen={onVideoFullScreen}
@@ -38,9 +38,26 @@ export function ChatVideoPlayer({
// Use video cache hook // Use video cache hook
const { cachedUri, cacheVideoFile } = useVideoCache(storagePath, signedUrl); const { cachedUri, cacheVideoFile } = useVideoCache(storagePath, signedUrl);
// For encrypted media, signedUrl is already the decrypted local file URI
// Skip the cache lookup for local file URIs
const isLocalFile = signedUrl?.startsWith('file://');
const videoSource = isLocalFile ? signedUrl : (cachedUri || signedUrl);
// Debug logging
useEffect(() => {
console.log(`[ChatVideoPlayer] isLocalFile=${isLocalFile}, storagePath=${storagePath}, signedUrl=${signedUrl?.substring(0, 60)}..., videoSource=${videoSource?.substring(0, 60)}...`);
}, [storagePath, signedUrl, videoSource, isLocalFile]);
// Handle playback status updates // Handle playback status updates
const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => { const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => {
if (!status.isLoaded) return; if (!status.isLoaded) {
// Log error if load failed
if ('error' in status && status.error) {
console.error(`[ChatVideoPlayer] Video load error:`, status.error);
setIsLoading(false);
}
return;
}
setIsLoading(false); setIsLoading(false);
setIsPlaying(status.isPlaying); setIsPlaying(status.isPlaying);
@@ -49,12 +66,12 @@ export function ChatVideoPlayer({
setHasEnded(true); setHasEnded(true);
setIsPlaying(false); setIsPlaying(false);
// Cache in background if not already cached // Cache in background if not already cached (only for remote URLs)
if (signedUrl && !cachedUri) { if (signedUrl && !cachedUri && !isLocalFile) {
cacheVideoFile(); cacheVideoFile();
} }
} }
}, [signedUrl, storagePath, cachedUri]); }, [signedUrl, storagePath, cachedUri, isLocalFile]);
// Toggle play/pause with native-like behavior // Toggle play/pause with native-like behavior
const handleTapToPlay = useCallback(async () => { const handleTapToPlay = useCallback(async () => {
@@ -90,8 +107,6 @@ export function ChatVideoPlayer({
} }
}, [cachedUri, signedUrl, isPlaying, onFullScreen]); }, [cachedUri, signedUrl, isPlaying, onFullScreen]);
const videoSource = cachedUri || signedUrl;
if (urlError || !videoSource) { if (urlError || !videoSource) {
return ( return (
<View style={[styles.video, styles.errorContainer]}> <View style={[styles.video, styles.errorContainer]}>
@@ -3,7 +3,7 @@
* Applies gradient styling for sender vs receiver messages. * Applies gradient styling for sender vs receiver messages.
*/ */
import React from 'react'; import React from 'react';
import { View, StyleSheet } from 'react-native'; import { View, StyleSheet, TouchableOpacity } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInUp } from 'react-native-reanimated'; import Animated, { FadeInUp } from 'react-native-reanimated';
import { colors, gradients, spacing, radius } from '../../../theme'; import { colors, gradients, spacing, radius } from '../../../theme';
@@ -14,16 +14,23 @@ export interface MessageBubbleProps {
isMe: boolean; isMe: boolean;
index: number; index: number;
children: React.ReactNode; children: React.ReactNode;
/** Called when the message bubble is long pressed */
onLongPress?: () => void;
} }
export function MessageBubble({ isMe, index, children }: MessageBubbleProps) { export function MessageBubble({ isMe, index, children, onLongPress }: MessageBubbleProps) {
return ( return (
<Animated.View <Animated.View
entering={FadeInUp.delay(index * 30).duration(200)} entering={FadeInUp.delay(index * 30).duration(200)}
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]} style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
> >
{isMe ? ( {isMe ? (
<View style={styles.myBubbleContainer}> <TouchableOpacity
onLongPress={onLongPress}
activeOpacity={0.9}
delayLongPress={300}
style={styles.myBubbleContainer}
>
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={gradients.primary as [string, string]}
style={[styles.bubble, styles.myBubble]} style={[styles.bubble, styles.myBubble]}
@@ -39,9 +46,14 @@ export function MessageBubble({ isMe, index, children }: MessageBubbleProps) {
/> />
{children} {children}
</LinearGradient> </LinearGradient>
</View> </TouchableOpacity>
) : ( ) : (
<View style={styles.theirBubbleContainer}> <TouchableOpacity
onLongPress={onLongPress}
activeOpacity={0.9}
delayLongPress={300}
style={styles.theirBubbleContainer}
>
<View style={[styles.bubble, styles.theirBubble]}> <View style={[styles.bubble, styles.theirBubble]}>
{/* Subtle gradient background */} {/* Subtle gradient background */}
<LinearGradient <LinearGradient
@@ -59,7 +71,7 @@ export function MessageBubble({ isMe, index, children }: MessageBubbleProps) {
/> />
{children} {children}
</View> </View>
</View> </TouchableOpacity>
)} )}
</Animated.View> </Animated.View>
); );
@@ -1,16 +1,18 @@
/** /**
* MessageContent - Renders message content (text, images, videos) * MessageContent - Renders message content (text, images, videos)
* Handles media reveal logic, expired media, and loading states. * Handles media reveal logic, expired media, and loading states.
* Supports both v1 (plaintext) and v2 (E2EE encrypted) messages.
*/ */
import React, { useState, useEffect } from 'react'; import React from 'react';
import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native'; import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator, Alert } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme'; import { colors, gradients, spacing, radius } from '../../../theme';
import { getCachedSignedUrl, getStoragePath } from '../../../lib/imageCache';
import { MessageMeta } from './MessageMeta'; import { MessageMeta } from './MessageMeta';
import { ChatVideoPlayer } from './ChatVideoPlayer'; import { ChatVideoPlayer } from './ChatVideoPlayer';
import { useDecryptedMessage, useDecryptedMedia } from '../../../hooks';
import type { KeysMetadata } from '../../../lib/encryption';
import type { Message } from '../types'; import type { Message } from '../types';
const ACCENT = colors.premium.gold; const ACCENT = colors.premium.gold;
@@ -18,6 +20,7 @@ const ACCENT = colors.premium.gold;
export interface MessageContentProps { export interface MessageContentProps {
item: Message; item: Message;
isMe: boolean; isMe: boolean;
currentUserId: string;
revealMessage: (id: string) => void; revealMessage: (id: string) => void;
onImagePress: (uri: string) => void; onImagePress: (uri: string) => void;
onVideoFullScreen: (uri: string) => void; onVideoFullScreen: (uri: string) => void;
@@ -26,52 +29,83 @@ export interface MessageContentProps {
export function MessageContent({ export function MessageContent({
item, item,
isMe, isMe,
currentUserId,
revealMessage, revealMessage,
onImagePress, onImagePress,
onVideoFullScreen, onVideoFullScreen,
}: MessageContentProps) { }: MessageContentProps) {
const [signedUrl, setSignedUrl] = useState<string | null>(null); // Check if message was deleted for everyone
const [urlError, setUrlError] = useState(false); if (item.deleted_at) {
return (
<View style={styles.deletedContainer}>
<Ionicons
name="ban-outline"
size={14}
color={colors.textTertiary}
/>
<Text style={styles.deletedText}>
{isMe ? 'You deleted this message' : 'This message was deleted'}
</Text>
</View>
);
}
// Use decryption hook for E2EE text messages
const {
content: decryptedContent,
isDecrypting,
error: decryptError,
errorCode: decryptErrorCode,
retry: retryDecrypt,
} = useDecryptedMessage({
message: {
id: item.id,
content: item.content,
version: item.version,
encrypted_content: item.encrypted_content,
encryption_iv: item.encryption_iv,
keys_metadata: item.keys_metadata as KeysMetadata | null,
user_id: item.user_id,
},
currentUserId,
});
const isVideo = (item as any).media_type === 'video'; const isVideo = (item as any).media_type === 'video';
const isViewed = !!item.media_viewed_at; const isViewed = !!item.media_viewed_at;
const isRecipientHidden = !isMe && !isViewed; const isRecipientHidden = !isMe && !isViewed;
useEffect(() => { // Use decryption hook for E2EE media (images/videos)
if (!item.media_path) { const {
setSignedUrl(null); uri: mediaUri,
setUrlError(false); isDecrypting: isDecryptingMedia,
return; error: mediaError,
} errorCode: mediaErrorCode,
retry: retryMedia,
isEncrypted: isMediaEncrypted,
} = useDecryptedMedia({
messageId: item.id,
mediaPath: item.media_path ?? null,
version: item.version,
encryptionIv: item.encryption_iv,
keysMetadata: item.keys_metadata as KeysMetadata | null,
isMe,
mediaType: isVideo ? 'video' : 'image',
shouldFetch: !!item.media_path && !isRecipientHidden,
});
if (isRecipientHidden) { const isPendingMedia = mediaErrorCode === 'E2EE_PENDING_RECIPIENT_KEY';
setSignedUrl(null);
setUrlError(false);
return;
}
let isMounted = true; const showSecureMediaInfo = () => {
Alert.alert(
const fetchSignedUrl = async () => { 'Secure media',
const storagePath = getStoragePath(item.media_path!); "To keep your chats private, photos and videos are locked to each person’s secure key.\n\nThis media was sent before your secure key was ready, so it’s still locked. We’re now securely updating the lock for your device (without exposing the media) so you can open it.\n\nIf it doesn’t unlock in a few seconds, tap “Try again”.",
const url = await getCachedSignedUrl(storagePath); [
{ text: 'OK', style: 'cancel' },
if (!isMounted) return; { text: 'Try again', onPress: () => retryMedia() },
]
if (url) { );
setSignedUrl(url);
} else {
setUrlError(true);
}
}; };
fetchSignedUrl();
return () => {
isMounted = false;
};
}, [item.media_path, isRecipientHidden]);
// Handle expired videos // Handle expired videos
const isExpired = !!(item as any).media_expired; const isExpired = !!(item as any).media_expired;
@@ -99,29 +133,26 @@ export function MessageContent({
// Handle media (images and videos) // Handle media (images and videos)
if (item.media_path || isExpired) { if (item.media_path || isExpired) {
const canOpenFullScreen = (isMe || isViewed) && !isVideo; // Only images can go fullscreen const canOpenFullScreen = (isMe || isViewed) && !isVideo; // Only images can go fullscreen
const hasMediaError = !!mediaError;
const isLoading = isDecryptingMedia || (!mediaUri && !hasMediaError && !isRecipientHidden);
// Video content // Video content
if (isVideo) { if (isVideo) {
return ( return (
<View> <View>
{isRecipientHidden ? ( {isRecipientHidden ? (
// Blurred placeholder with reveal overlay for unrevealed videos // Clean reveal UI for unrevealed videos
<View> <TouchableOpacity
<View style={[styles.messageVideo, styles.videoBlurred]}> style={[styles.messageVideo, styles.unrevealedPlaceholder]}
activeOpacity={0.8}
onPress={() => revealMessage(item.id)}
>
<LinearGradient <LinearGradient
colors={['rgba(22, 33, 62, 0.9)', 'rgba(13, 13, 26, 0.95)']} colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
/> />
<Ionicons name="videocam" size={40} color={colors.textTertiary} />
</View>
<TouchableOpacity
style={styles.revealOverlay}
activeOpacity={0.8}
onPress={() => revealMessage(item.id)}
>
<View style={styles.revealContent}>
<LinearGradient <LinearGradient
colors={gradients.premiumGold as [string, string]} colors={gradients.premiumGold as [string, string]}
style={styles.revealIconContainer} style={styles.revealIconContainer}
@@ -130,20 +161,35 @@ export function MessageContent({
> >
<Ionicons name="eye-off-outline" size={24} color={colors.text} /> <Ionicons name="eye-off-outline" size={24} color={colors.text} />
</LinearGradient> </LinearGradient>
<Text style={styles.revealText}>Tap to reveal video</Text> <Text style={styles.unrevealedText}>Video hidden</Text>
<Text style={styles.revealHint}>Tap to reveal</Text>
</TouchableOpacity>
) : isPendingMedia ? (
<TouchableOpacity activeOpacity={0.85} onPress={showSecureMediaInfo}>
<View style={[styles.messageVideo, styles.secureMediaPlaceholder]}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.85)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons name="lock-closed-outline" size={32} color={colors.textSecondary} />
<Text style={styles.secureMediaPlaceholderText}>Secure video</Text>
<Text style={styles.secureMediaPlaceholderSubtext}>Finishing security setup • Tap for info</Text>
{isDecryptingMedia && <ActivityIndicator color={ACCENT} />}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
</View> ) : mediaUri || hasMediaError ? (
) : signedUrl || urlError ? (
<ChatVideoPlayer <ChatVideoPlayer
signedUrl={signedUrl} signedUrl={mediaUri}
storagePath={item.media_path!} storagePath={item.media_path!}
urlError={urlError} urlError={hasMediaError}
onFullScreen={onVideoFullScreen} onFullScreen={onVideoFullScreen}
/> />
) : ( ) : (
<View style={[styles.messageVideo, styles.messageImageLoading]}> <View style={[styles.messageVideo, styles.messageImageLoading]}>
<ActivityIndicator color={ACCENT} /> <ActivityIndicator color={ACCENT} />
<Text style={styles.decryptingText}>Loading...</Text>
</View> </View>
)} )}
{/* Viewed indicator for sender */} {/* Viewed indicator for sender */}
@@ -162,50 +208,30 @@ export function MessageContent({
return ( return (
<View> <View>
<TouchableOpacity <TouchableOpacity
activeOpacity={canOpenFullScreen ? 0.8 : 1} activeOpacity={isPendingMedia || canOpenFullScreen ? 0.8 : 1}
onPress={() => canOpenFullScreen && signedUrl && onImagePress(signedUrl)} onPress={() => {
disabled={!canOpenFullScreen || !signedUrl} if (isPendingMedia) {
showSecureMediaInfo();
return;
}
if (canOpenFullScreen && mediaUri) {
onImagePress(mediaUri);
}
}}
disabled={!(isPendingMedia || (canOpenFullScreen && !!mediaUri))}
> >
{isRecipientHidden ? ( {isRecipientHidden ? (
<View style={[styles.messageImage, styles.unrevealedPlaceholder]}> <TouchableOpacity
style={[styles.messageImage, styles.unrevealedPlaceholder]}
activeOpacity={0.8}
onPress={() => revealMessage(item.id)}
>
<LinearGradient <LinearGradient
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']} colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
/> />
<View style={styles.unrevealedIconContainer}>
<Ionicons name="image-outline" size={28} color={colors.text} />
</View>
<Text style={styles.unrevealedText}>Photo hidden</Text>
</View>
) : signedUrl ? (
<Image
source={{ uri: signedUrl }}
style={styles.messageImage}
blurRadius={0}
cachePolicy="disk"
transition={200}
/>
) : urlError ? (
<View style={[styles.messageImage, styles.messageImageError]}>
<Ionicons name="image-outline" size={32} color={colors.textSecondary} />
<Text style={styles.messageImageErrorText}>Image unavailable</Text>
</View>
) : (
<View style={[styles.messageImage, styles.messageImageLoading]}>
<ActivityIndicator color={ACCENT} />
</View>
)}
</TouchableOpacity>
{/* Reveal overlay for recipient */}
{isRecipientHidden && (
<TouchableOpacity
style={styles.revealOverlay}
activeOpacity={0.8}
onPress={() => revealMessage(item.id)}
>
<View style={styles.revealContent}>
<LinearGradient <LinearGradient
colors={gradients.premiumGold as [string, string]} colors={gradients.premiumGold as [string, string]}
style={styles.revealIconContainer} style={styles.revealIconContainer}
@@ -214,10 +240,42 @@ export function MessageContent({
> >
<Ionicons name="eye-off-outline" size={24} color={colors.text} /> <Ionicons name="eye-off-outline" size={24} color={colors.text} />
</LinearGradient> </LinearGradient>
<Text style={styles.revealText}>Tap to reveal</Text> <Text style={styles.unrevealedText}>Photo hidden</Text>
</View> <Text style={styles.revealHint}>Tap to reveal</Text>
</TouchableOpacity> </TouchableOpacity>
) : mediaUri ? (
<Image
source={{ uri: mediaUri }}
style={styles.messageImage}
blurRadius={0}
cachePolicy="disk"
transition={200}
/>
) : isPendingMedia ? (
<View style={[styles.messageImage, styles.secureMediaPlaceholder]}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.85)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons name="lock-closed-outline" size={32} color={colors.textSecondary} />
<Text style={styles.secureMediaPlaceholderText}>Secure photo</Text>
<Text style={styles.secureMediaPlaceholderSubtext}>Finishing security setup • Tap for info</Text>
{isDecryptingMedia && <ActivityIndicator color={ACCENT} />}
</View>
) : hasMediaError ? (
<View style={[styles.messageImage, styles.messageImageError]}>
<Ionicons name="image-outline" size={32} color={colors.textSecondary} />
<Text style={styles.messageImageErrorText}>Failed to load image</Text>
</View>
) : (
<View style={[styles.messageImage, styles.messageImageLoading]}>
<ActivityIndicator color={ACCENT} />
<Text style={styles.decryptingText}>Loading...</Text>
</View>
)} )}
</TouchableOpacity>
{/* Viewed indicator for sender */} {/* Viewed indicator for sender */}
{isMe && isViewed && ( {isMe && isViewed && (
<View style={styles.viewedBadge}> <View style={styles.viewedBadge}>
@@ -231,26 +289,139 @@ export function MessageContent({
} }
// Text message // Text message
const version = item.version ?? 1;
const isV2 = version === 2;
const showSecureMessageInfo = () => {
Alert.alert(
'Secure message',
"To keep your chats private, each message is locked to your device’s secure key.\n\nThis message was sent before your secure key was ready, so it’s still locked. We’re now securely updating the lock for your device (without exposing the message content) so you can read it.\n\nIf it doesn’t unlock in a few seconds, tap “Try again”.",
[
{ text: 'OK', style: 'cancel' },
{ text: 'Try again', onPress: () => retryDecrypt() },
]
);
};
const renderText = () => {
if (decryptErrorCode === 'E2EE_PENDING_RECIPIENT_KEY') {
return (
<TouchableOpacity activeOpacity={0.85} onPress={showSecureMessageInfo}>
<View style={styles.securePlaceholder}>
<View style={styles.securePlaceholderRow}>
<Ionicons name="lock-closed-outline" size={16} color={colors.textSecondary} />
<Text style={styles.securePlaceholderTitle}>Secure message</Text>
{isDecrypting && <ActivityIndicator size="small" color={ACCENT} />}
</View>
<Text style={styles.securePlaceholderSubtext}>Finishing security setup • Tap for info</Text>
</View>
</TouchableOpacity>
);
}
if (isDecrypting) {
return (
<View style={styles.decryptingContainer}>
<ActivityIndicator size="small" color={ACCENT} />
<Text style={styles.decryptingText}>Loading...</Text>
</View>
);
}
if (decryptError) {
return (
<TouchableOpacity activeOpacity={0.85} onPress={retryDecrypt}>
<Text style={[styles.messageText, styles.errorText]}>Couldn’t open secure message • Tap to retry</Text>
</TouchableOpacity>
);
}
if (isV2) {
return <Text style={styles.messageText}>{decryptedContent ?? 'Secure message'}</Text>;
}
return <Text style={styles.messageText}>{item.content ?? 'Failed to load message'}</Text>;
};
return ( return (
<> <>
<Text style={styles.messageText}>{item.content}</Text> {renderText()}
<MessageMeta item={item} isMe={isMe} /> <MessageMeta item={item} isMe={isMe} />
</> </>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
deletedContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
deletedText: {
color: colors.textTertiary,
fontSize: 14,
fontStyle: 'italic',
},
messageText: { messageText: {
color: colors.text, color: colors.text,
fontSize: 16, fontSize: 16,
lineHeight: 22, lineHeight: 22,
}, },
securePlaceholder: {
gap: spacing.xs,
},
securePlaceholderRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
securePlaceholderTitle: {
color: colors.textSecondary,
fontSize: 14,
fontWeight: '600',
},
securePlaceholderSubtext: {
color: colors.textTertiary,
fontSize: 12,
},
decryptingContainer: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
justifyContent: 'center',
alignItems: 'center',
},
decryptingText: {
color: colors.textSecondary,
fontSize: 11,
marginTop: spacing.xs,
},
errorText: {
color: colors.textSecondary,
fontStyle: 'italic',
},
messageImage: { messageImage: {
width: 200, width: 200,
height: 200, height: 200,
borderRadius: radius.md, borderRadius: radius.md,
backgroundColor: 'rgba(255, 255, 255, 0.05)', backgroundColor: 'rgba(255, 255, 255, 0.05)',
}, },
secureMediaPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
gap: spacing.sm,
overflow: 'hidden',
},
secureMediaPlaceholderText: {
color: colors.textSecondary,
fontSize: 12,
fontWeight: '600',
},
secureMediaPlaceholderSubtext: {
color: colors.textTertiary,
fontSize: 11,
textAlign: 'center',
paddingHorizontal: spacing.md,
},
messageImageLoading: { messageImageLoading: {
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
@@ -270,21 +441,15 @@ const styles = StyleSheet.create({
gap: spacing.sm, gap: spacing.sm,
overflow: 'hidden', overflow: 'hidden',
}, },
unrevealedIconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.15)',
justifyContent: 'center',
alignItems: 'center',
},
unrevealedText: { unrevealedText: {
color: colors.textSecondary, color: colors.text,
fontSize: 12, fontSize: 13,
fontWeight: '600', fontWeight: '600',
letterSpacing: 0.3, },
revealHint: {
color: gradients.premiumGold[0],
fontSize: 12,
fontWeight: '500',
}, },
messageVideo: { messageVideo: {
width: 200, width: 200,
@@ -293,20 +458,6 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255, 255, 255, 0.05)', backgroundColor: 'rgba(255, 255, 255, 0.05)',
overflow: 'hidden', overflow: 'hidden',
}, },
videoBlurred: {
justifyContent: 'center',
alignItems: 'center',
},
revealOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
borderRadius: radius.md,
},
revealContent: {
alignItems: 'center',
gap: spacing.sm,
},
revealIconContainer: { revealIconContainer: {
width: 48, width: 48,
height: 48, height: 48,
@@ -314,11 +465,6 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
revealText: {
color: colors.text,
fontSize: 13,
fontWeight: '600',
},
viewedBadge: { viewedBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -7,11 +7,26 @@ import { decode } from 'base64-arraybuffer';
import { supabase } from '../../../lib/supabase'; import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics'; import { Events } from '../../../lib/analytics';
import { UploadStatus } from '../types'; import { UploadStatus } from '../types';
import { useAuthStore } from '../../../store';
import {
encryptMediaFile,
getAdminPublicKey,
getAdminKeyId,
MESSAGE_VERSION_E2EE,
} from '../../../lib/encryption';
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
import { useEncryptionKeys } from '../../../hooks';
export function useMediaUpload(matchId: string, userId: string | undefined) { export function useMediaUpload(matchId: string, userId: string | undefined) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState<UploadStatus>(null); const [uploadStatus, setUploadStatus] = useState<UploadStatus>(null);
const refreshPartner = useAuthStore((s) => s.refreshPartner);
const { publicKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
// Check if E2EE is available for media
const isE2EEAvailable = Platform.OS !== 'web' && hasKeys && !keysLoading && !!publicKeyJwk;
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => { const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
if (!userId) return; if (!userId) return;
setUploading(true); setUploading(true);
@@ -21,6 +36,7 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
let fileUri = uri; let fileUri = uri;
let fileBody; let fileBody;
let ext = mediaType === 'video' ? 'mp4' : 'jpg'; let ext = mediaType === 'video' ? 'mp4' : 'jpg';
let encryptedPayload: EncryptedMediaPayload | null = null;
// Compress videos on native platforms before upload // Compress videos on native platforms before upload
if (mediaType === 'video' && Platform.OS !== 'web') { if (mediaType === 'video' && Platform.OS !== 'web') {
@@ -43,6 +59,49 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
} }
} }
// Try to encrypt the media if E2EE is available
if (isE2EEAvailable && Platform.OS !== 'web') {
try {
// CRITICAL: Read keys directly from global store at encryption time
// This ensures we always use the most current keys, not potentially stale hook state
const currentKeys = useAuthStore.getState().encryptionKeys;
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk) {
console.warn('[E2EE Media Upload] No keys available in global store at encryption time');
throw new Error('No encryption keys available');
}
const senderPublicKey = currentKeys.publicKeyJwk;
console.log('[E2EE Media Upload] Using sender public key from global store, version:', currentKeys.keysVersion);
// Refresh partner's profile to get their latest public key
console.log('[E2EE Media Upload] Refreshing partner profile...');
const freshPartner = await refreshPartner();
const adminPublicKey = await getAdminPublicKey();
const adminKeyId = await getAdminKeyId();
const partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
console.log('[E2EE Media Upload] Partner public key available:', !!partnerPublicKey);
encryptedPayload = await encryptMediaFile(
fileUri,
senderPublicKey as RSAPublicKeyJWK,
partnerPublicKey ?? null,
adminPublicKey,
adminKeyId
);
// Use the encrypted file for upload
fileUri = encryptedPayload.encryptedFileUri;
ext = 'enc'; // Encrypted files use .enc extension
console.log('Media encrypted successfully');
} catch (encryptError) {
console.warn('Media encryption failed, uploading unencrypted:', encryptError);
encryptedPayload = null;
}
}
if (Platform.OS === 'web') { if (Platform.OS === 'web') {
const response = await fetch(fileUri); const response = await fetch(fileUri);
const blob = await response.blob(); const blob = await response.blob();
@@ -63,8 +122,8 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 }); const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
fileBody = decode(base64); fileBody = decode(base64);
if (mediaType !== 'video') { if (mediaType !== 'video' && !encryptedPayload) {
// Only use URI extension for non-videos (videos are always mp4 after compression) // Only use URI extension for non-videos and non-encrypted (videos are always mp4 after compression)
const uriExt = fileUri.split('.').pop(); const uriExt = fileUri.split('.').pop();
if (uriExt && uriExt !== fileUri) { if (uriExt && uriExt !== fileUri) {
ext = uriExt.toLowerCase(); ext = uriExt.toLowerCase();
@@ -73,7 +132,9 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
} }
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`; const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
const contentType = mediaType === 'video' const contentType = encryptedPayload
? 'application/octet-stream' // Encrypted files are binary
: mediaType === 'video'
? `video/${ext === 'mov' ? 'quicktime' : ext}` ? `video/${ext === 'mov' ? 'quicktime' : ext}`
: `image/${ext === 'jpg' ? 'jpeg' : ext}`; : `image/${ext === 'jpg' ? 'jpeg' : ext}`;
@@ -86,7 +147,30 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
if (uploadError) throw uploadError; if (uploadError) throw uploadError;
// Store just the path, not the full URL (bucket is private) // Clean up encrypted temp file
if (encryptedPayload && encryptedPayload.encryptedFileUri) {
try {
await FileSystem.deleteAsync(encryptedPayload.encryptedFileUri, { idempotent: true });
} catch (cleanupError) {
console.warn('Failed to clean up encrypted temp file:', cleanupError);
}
}
// Store the message with encryption metadata if encrypted
if (encryptedPayload) {
await supabase.from("messages").insert({
match_id: matchId,
user_id: userId,
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
media_path: fileName,
media_type: mediaType,
media_expired: false,
version: MESSAGE_VERSION_E2EE,
encryption_iv: encryptedPayload.encryption_iv,
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
});
} else {
// Unencrypted upload (v1)
await supabase.from("messages").insert({ await supabase.from("messages").insert({
match_id: matchId, match_id: matchId,
user_id: userId, user_id: userId,
@@ -95,6 +179,7 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
media_type: mediaType, media_type: mediaType,
media_expired: false, media_expired: false,
}); });
}
Events.mediaUploaded(); Events.mediaUploaded();
} catch (error) { } catch (error) {
@@ -0,0 +1,102 @@
/**
* useMessageActions - Hook for message deletion actions
* Handles "delete for self" and "delete for everyone" functionality.
*/
import { Platform, ActionSheetIOS, Alert } from 'react-native';
import * as Haptics from 'expo-haptics';
import { supabase } from '../../../lib/supabase';
import type { Message } from '../types';
interface UseMessageActionsConfig {
userId: string | undefined;
}
export function useMessageActions({ userId }: UseMessageActionsConfig) {
/**
* Delete a message for the current user only.
* Message will still be visible to the partner.
*/
const deleteForSelf = async (messageId: string) => {
if (!userId) return;
const { error } = await supabase
.from('message_deletions')
.insert({ message_id: messageId, user_id: userId });
if (error) {
Alert.alert('Error', 'Failed to delete message');
console.error('Delete for self error:', error);
}
};
/**
* Delete a message for everyone (author only).
* Message will show as "deleted" to both users.
*/
const deleteForEveryone = async (messageId: string) => {
const { error } = await supabase
.from('messages')
.update({ deleted_at: new Date().toISOString() })
.eq('id', messageId);
if (error) {
Alert.alert('Error', 'Failed to delete message');
console.error('Delete for everyone error:', error);
}
};
/**
* Show delete options for a message.
* Author gets both options, non-author only gets "delete for me".
*/
const showDeleteOptions = (message: Message, isMe: boolean) => {
// Don't show options for already deleted messages
if (message.deleted_at) return;
// Haptic feedback
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
if (Platform.OS === 'ios') {
const options = ['Cancel', 'Delete for me'];
if (isMe) options.push('Delete for everyone');
ActionSheetIOS.showActionSheetWithOptions(
{
options,
destructiveButtonIndex: isMe ? 2 : 1,
cancelButtonIndex: 0,
title: 'Delete message?',
},
(buttonIndex) => {
if (buttonIndex === 1) {
deleteForSelf(message.id);
} else if (buttonIndex === 2 && isMe) {
deleteForEveryone(message.id);
}
}
);
} else {
// Android fallback using Alert
const buttons: Array<{ text: string; style?: 'cancel' | 'default' | 'destructive'; onPress?: () => void }> = [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete for me',
style: 'destructive',
onPress: () => deleteForSelf(message.id),
},
];
if (isMe) {
buttons.push({
text: 'Delete for everyone',
style: 'destructive',
onPress: () => deleteForEveryone(message.id),
});
}
Alert.alert('Delete message?', undefined, buttons);
}
};
return { showDeleteOptions, deleteForSelf, deleteForEveryone };
}
@@ -12,6 +12,7 @@ import { useAuthStore, useSubscriptionStore } from '../../store';
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen'; import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen'; import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
import { supabase } from '../../lib/supabase'; // Needed for debug reset import { supabase } from '../../lib/supabase'; // Needed for debug reset
import { clearKeys } from '../../lib/encryption';
// Hooks // Hooks
import { useProfileSettings, useCoupleManagement } from './hooks'; import { useProfileSettings, useCoupleManagement } from './hooks';
@@ -296,6 +297,30 @@ export function ProfileScreen() {
} }
}} }}
/> />
<View style={styles.preferencesDivider} />
<MenuItem
icon="key-outline"
label="Clear Private Key"
description="Remove E2EE keys from device"
onPress={() => {
Alert.alert(
"Clear Keys",
"Are you sure? You will lose access to encrypted messages until keys are regenerated.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Clear",
style: "destructive",
onPress: async () => {
await clearKeys();
Alert.alert("Success", "Keys cleared. They will be regenerated on next launch/login.");
}
}
]
);
}}
variant="danger"
/>
</SettingsSection> </SettingsSection>
)} )}
+5
View File
@@ -7,3 +7,8 @@ export { useTypingIndicator } from './useTypingIndicator';
export type { UseTypingIndicatorConfig, UseTypingIndicatorReturn } from './useTypingIndicator'; export type { UseTypingIndicatorConfig, UseTypingIndicatorReturn } from './useTypingIndicator';
export { useMessageSubscription } from './useMessageSubscription'; export { useMessageSubscription } from './useMessageSubscription';
export type { UseMessageSubscriptionConfig, UseMessageSubscriptionReturn } from './useMessageSubscription'; export type { UseMessageSubscriptionConfig, UseMessageSubscriptionReturn } from './useMessageSubscription';
export { useEncryptionKeys } from './useEncryptionKeys';
export { useDecryptedMessage } from './useDecryptedMessage';
export { useEncryptedSend } from './useEncryptedSend';
export { useDecryptedMedia, clearDecryptedMediaCache } from './useDecryptedMedia';
export { useMediaSaver } from './useMediaSaver';
+332
View File
@@ -0,0 +1,332 @@
/**
* useDecryptedMedia Hook
*
* React hook for decrypting E2EE encrypted media files (images/videos).
* Handles downloading, decrypting, and caching decrypted media.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import { decryptMediaFile, cleanupDecryptedMedia, MESSAGE_VERSION_E2EE } from '../lib/encryption';
import type { KeysMetadata, RSAPrivateKeyJWK } from '../lib/encryption';
import { useEncryptionKeys } from './useEncryptionKeys';
import { getCachedSignedUrl, getStoragePath } from '../lib/imageCache';
import { supabase } from '../lib/supabase';
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
export type DecryptedMediaErrorCode =
| 'E2EE_NOT_SUPPORTED'
| 'E2EE_KEYS_UNAVAILABLE'
| 'E2EE_MISSING_FIELDS'
| 'E2EE_PENDING_RECIPIENT_KEY'
| 'E2EE_DECRYPT_FAILED';
// In-memory cache to avoid re-decrypting the same media
const decryptedMediaCache = new Map<string, string>();
interface DecryptedMediaState {
/** Local file URI to the decrypted media (or null if not ready) */
uri: string | null;
/** Whether decryption is in progress */
isDecrypting: boolean;
/** Error if decryption failed */
error: Error | null;
/** Optional error code for UX */
errorCode?: DecryptedMediaErrorCode;
}
interface UseDecryptedMediaOptions {
/** The message ID (used for cache key) */
messageId: string;
/** Path to the media file in storage */
mediaPath: string | null;
/** Message version (1 = plaintext, 2 = encrypted) */
version: number | null | undefined;
/** Encryption IV (required for v2) */
encryptionIv: string | null | undefined;
/** Keys metadata containing wrapped keys (required for v2) */
keysMetadata: KeysMetadata | null | undefined;
/** Whether current user is the sender */
isMe: boolean;
/** Media type */
mediaType: 'image' | 'video';
/** Whether media should be fetched (e.g., not hidden) */
shouldFetch: boolean;
}
export type UseDecryptedMediaResult = DecryptedMediaState & {
isEncrypted: boolean;
retry: () => void;
};
/**
* Hook to decrypt encrypted media files
*/
export function useDecryptedMedia({
messageId,
mediaPath,
version,
encryptionIv,
keysMetadata,
isMe,
mediaType,
shouldFetch,
}: UseDecryptedMediaOptions): UseDecryptedMediaResult {
const [state, setState] = useState<DecryptedMediaState>({
uri: null,
isDecrypting: false,
error: null,
});
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
const isMounted = useRef(true);
const repairAttemptedByMessageId = useRef(new Set<string>());
const [retryNonce, setRetryNonce] = useState(0);
const retry = useCallback(() => {
repairAttemptedByMessageId.current.delete(messageId);
setRetryNonce((n) => n + 1);
}, [messageId]);
// Determine if this is encrypted media
const isEncrypted = version === MESSAGE_VERSION_E2EE && (mediaPath?.endsWith('.enc') ?? false);
const decryptMedia = useCallback(async () => {
// No media path - nothing to do
if (!mediaPath || !shouldFetch) {
setState({ uri: null, isDecrypting: false, error: null, errorCode: undefined });
return;
}
console.log(
`[E2EE Media] Message ${messageId}: Starting decrypt - version=${version}, mediaPath=${mediaPath}, isEncrypted=${isEncrypted}`,
);
// Check cache first
const cacheKey = `${messageId}-${mediaPath}`;
const cachedUri = decryptedMediaCache.get(cacheKey);
if (cachedUri) {
try {
const fileInfo = await FileSystem.getInfoAsync(cachedUri);
if (fileInfo.exists) {
console.log(`[E2EE Media] Message ${messageId}: Using cached decrypted file`);
setState({ uri: cachedUri, isDecrypting: false, error: null, errorCode: undefined });
return;
}
} catch {
decryptedMediaCache.delete(cacheKey);
}
}
// v1 (plaintext) or non-.enc files: Use existing signed URL flow
if (!isEncrypted) {
console.log(`[E2EE Media] Message ${messageId}: Not encrypted, using signed URL flow`);
try {
const storagePath = getStoragePath(mediaPath);
const signedUrl = await getCachedSignedUrl(storagePath);
if (isMounted.current) {
setState({
uri: signedUrl,
isDecrypting: false,
error: signedUrl ? null : new Error('Failed to get signed URL'),
errorCode: signedUrl ? undefined : 'E2EE_DECRYPT_FAILED',
});
}
} catch (err) {
console.error(`[E2EE Media] Message ${messageId}: Failed to get signed URL:`, err);
if (isMounted.current) {
setState({
uri: null,
isDecrypting: false,
error: err as Error,
errorCode: 'E2EE_DECRYPT_FAILED',
});
}
}
return;
}
// v2 (encrypted) media - requires decryption
console.log(`[E2EE Media] Message ${messageId}: Encrypted media, starting decryption`);
if (Platform.OS === 'web') {
setState({
uri: null,
isDecrypting: false,
error: new Error('Failed to load media'),
errorCode: 'E2EE_NOT_SUPPORTED',
});
return;
}
// Wait for keys to load
if (keysLoading) {
console.log(`[E2EE Media] Message ${messageId}: Keys still loading...`);
setState((prev) => ({ ...prev, isDecrypting: true }));
return;
}
if (!hasKeys || !privateKeyJwk) {
console.log(
`[E2EE Media] Message ${messageId}: No keys available - hasKeys=${hasKeys}, hasPrivateKey=${!!privateKeyJwk}`,
);
setState({
uri: null,
isDecrypting: false,
error: new Error('Failed to load media'),
errorCode: 'E2EE_KEYS_UNAVAILABLE',
});
return;
}
// Validate required encryption fields
if (!encryptionIv || !keysMetadata) {
console.log(
`[E2EE Media] Message ${messageId}: Missing encryption metadata - iv=${!!encryptionIv}, keysMetadata=${!!keysMetadata}`,
);
setState({
uri: null,
isDecrypting: false,
error: new Error('Failed to load media'),
errorCode: 'E2EE_MISSING_FIELDS',
});
return;
}
// If the recipient key is missing, try server-assisted repair (admin recovery model).
let effectiveKeysMetadata: KeysMetadata | null = keysMetadata;
const getWrappedKey = (metadata: KeysMetadata | null): string | undefined =>
isMe ? metadata?.sender_wrapped_key : metadata?.recipient_wrapped_key;
let wrappedKey = getWrappedKey(effectiveKeysMetadata);
if (!wrappedKey) {
console.log(
`[E2EE Media] Message ${messageId}: No wrapped key for ${isMe ? 'sender' : 'recipient'} - pending_recipient=${(effectiveKeysMetadata as any)?.pending_recipient}`,
);
if (!isMe) {
setState({
uri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
if (!repairAttemptedByMessageId.current.has(messageId)) {
repairAttemptedByMessageId.current.add(messageId);
await reencryptPendingMessages();
}
const { data: refreshed } = await supabase
.from('messages')
.select('keys_metadata')
.eq('id', messageId)
.maybeSingle();
effectiveKeysMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
wrappedKey = getWrappedKey(effectiveKeysMetadata);
}
if (!wrappedKey) {
setState({
uri: null,
isDecrypting: false,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
return;
}
}
setState((prev) => ({ ...prev, isDecrypting: true, error: null, errorCode: undefined }));
try {
// Get signed URL for the encrypted file
const storagePath = getStoragePath(mediaPath);
console.log(`[E2EE Media] Message ${messageId}: Getting signed URL for ${storagePath}`);
const encryptedUrl = await getCachedSignedUrl(storagePath);
if (!encryptedUrl) {
throw new Error('Failed to load media');
}
console.log(`[E2EE Media] Message ${messageId}: Decrypting ${mediaType}...`);
const decryptedUri = await decryptMediaFile(
encryptedUrl,
encryptionIv,
effectiveKeysMetadata as KeysMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
!isMe, // isRecipient
mediaType,
);
console.log(`[E2EE Media] Message ${messageId}: Decryption successful - ${decryptedUri}`);
decryptedMediaCache.set(cacheKey, decryptedUri);
if (isMounted.current) {
setState({
uri: decryptedUri,
isDecrypting: false,
error: null,
errorCode: undefined,
});
}
} catch (err) {
console.error(`[E2EE Media] Message ${messageId}: Decryption failed:`, err);
if (isMounted.current) {
setState({
uri: null,
isDecrypting: false,
error: new Error('Failed to load media'),
errorCode: 'E2EE_DECRYPT_FAILED',
});
}
}
}, [
messageId,
mediaPath,
version,
encryptionIv,
keysMetadata,
isMe,
mediaType,
shouldFetch,
isEncrypted,
privateKeyJwk,
hasKeys,
keysLoading,
retryNonce,
]);
useEffect(() => {
isMounted.current = true;
decryptMedia();
return () => {
isMounted.current = false;
};
}, [decryptMedia, retryNonce]);
return {
...state,
isEncrypted,
retry,
};
}
/**
* Clear the decrypted media cache
* Call this when logging out or when memory is low
*/
export async function clearDecryptedMediaCache(): Promise<void> {
for (const uri of decryptedMediaCache.values()) {
await cleanupDecryptedMedia(uri);
}
decryptedMediaCache.clear();
}
@@ -0,0 +1,364 @@
/**
* useDecryptedMessage Hook
*
* React hook for decrypting E2EE messages.
* Handles both v1 (plaintext) and v2 (encrypted) messages.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Platform } from 'react-native';
import {
decryptTextMessage,
MESSAGE_VERSION_PLAINTEXT,
MESSAGE_VERSION_E2EE,
repairStaleKey,
} from '../lib/encryption';
import type {
DecryptedMessageState,
KeysMetadata,
RSAPrivateKeyJWK,
} from '../lib/encryption';
import { supabase } from '../lib/supabase';
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
import { useEncryptionKeys } from './useEncryptionKeys';
interface Message {
id: string;
content: string | null;
version?: number | null;
encrypted_content?: string | null;
encryption_iv?: string | null;
keys_metadata?: KeysMetadata | null;
user_id: string;
}
interface UseDecryptedMessageOptions {
/** The message to decrypt */
message: Message;
/** Current user's ID to determine if sender or recipient */
currentUserId: string;
}
export type UseDecryptedMessageResult = DecryptedMessageState & {
/** Force a re-attempt (used by UI retry affordances) */
retry: () => void;
};
/**
* Hook to decrypt a message
*
* Automatically handles:
* - v1 (plaintext) messages: returns content directly
* - v2 (encrypted) messages: decrypts using private key
* - Server-assisted re-wrap for pending recipient keys (admin recovery model)
*/
export function useDecryptedMessage({
message,
currentUserId,
}: UseDecryptedMessageOptions): UseDecryptedMessageResult {
const [state, setState] = useState<DecryptedMessageState>({
content: null,
mediaUri: null,
isDecrypting: false,
error: null,
});
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
// Track per-message repair attempts to avoid tight retry loops.
const repairAttemptedByMessageId = useRef(new Set<string>());
const [retryNonce, setRetryNonce] = useState(0);
const retry = useCallback(() => {
repairAttemptedByMessageId.current.delete(message.id);
setRetryNonce((n) => n + 1);
}, [message.id]);
const decrypt = useCallback(async () => {
// Version 1 or undefined: Legacy plaintext message
const version = message.version ?? MESSAGE_VERSION_PLAINTEXT;
console.log(
`[E2EE Decrypt] Message ${message.id}: version=${version}, hasEncryptedContent=${!!message.encrypted_content}, hasIV=${!!message.encryption_iv}, hasKeysMetadata=${!!message.keys_metadata}`,
);
if (version === MESSAGE_VERSION_PLAINTEXT) {
console.log(`[E2EE Decrypt] Message ${message.id}: Using plaintext content`);
setState({
content: message.content,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: undefined,
});
return;
}
// Version 2: E2EE - requires decryption
if (version === MESSAGE_VERSION_E2EE) {
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting E2EE decryption`);
// E2EE not supported on web
if (Platform.OS === 'web') {
console.log(`[E2EE Decrypt] Message ${message.id}: Web platform - E2EE not supported`);
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Failed to load message'),
errorCode: 'E2EE_NOT_SUPPORTED',
});
return;
}
// Wait for keys to load
if (keysLoading) {
console.log(`[E2EE Decrypt] Message ${message.id}: Keys still loading, waiting...`);
setState((prev) => ({ ...prev, isDecrypting: true }));
return;
}
if (!hasKeys || !privateKeyJwk) {
console.log(
`[E2EE Decrypt] Message ${message.id}: No keys available - hasKeys=${hasKeys}, hasPrivateKey=${!!privateKeyJwk}`,
);
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Failed to load message'),
errorCode: 'E2EE_KEYS_UNAVAILABLE',
});
return;
}
// Validate required encryption fields
if (!message.encrypted_content || !message.encryption_iv || !message.keys_metadata) {
console.log(
`[E2EE Decrypt] Message ${message.id}: Missing encryption fields - encrypted_content=${!!message.encrypted_content}, encryption_iv=${!!message.encryption_iv}, keys_metadata=${!!message.keys_metadata}`,
);
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Failed to load message'),
errorCode: 'E2EE_MISSING_FIELDS',
});
return;
}
setState((prev) => ({ ...prev, isDecrypting: true, error: null, errorCode: undefined }));
try {
const isMe = message.user_id === currentUserId;
console.log(`[E2EE Decrypt] Message ${message.id}: Decrypting as ${isMe ? 'sender' : 'recipient'}`);
console.log(
`[E2EE Decrypt] Message ${message.id}: keys_metadata=`,
JSON.stringify(message.keys_metadata, null, 2),
);
const decryptedContent = await decryptTextMessage(
message.encrypted_content,
message.encryption_iv,
message.keys_metadata as KeysMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
!isMe, // isRecipient
);
console.log(
`[E2EE Decrypt] Message ${message.id}: Decryption successful, content length=${decryptedContent.length}`,
);
setState({
content: decryptedContent,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: undefined,
});
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
console.error(`[E2EE Decrypt] Message ${message.id}: Decryption failed:`, error);
const isMe = message.user_id === currentUserId;
const isMissingWrappedKey = error.message.includes('No wrapped key available for decryption');
const metadata = message.keys_metadata as KeysMetadata;
// Check if this is a stale key failure (message encrypted with old key)
// This happens when: message was composed offline, recipient regenerated keys before sync
const isStaleKeyFailure =
error.name === 'OperationError' ||
error.message.includes('OperationError') ||
error.message.includes('decryption failed') ||
error.message.includes('decrypt error') ||
// react-native-quick-crypto specific error for RSA decryption failure
error.message.includes('error in DoCipher');
// Case 1: Missing wrapped key (pending_recipient)
// - Partner sent message before recipient had keys
// - keys_metadata has no recipient_wrapped_key
const shouldAttemptPendingRepair =
!isMe &&
isMissingWrappedKey &&
(!!metadata?.pending_recipient || !metadata?.recipient_wrapped_key);
// Case 2: Stale key failure
// - Message was encrypted with old public key
// - Need admin to re-wrap with current key
const shouldAttemptStaleKeyRepair =
isStaleKeyFailure &&
!isMissingWrappedKey &&
!repairAttemptedByMessageId.current.has(message.id);
if (shouldAttemptPendingRepair) {
setState({
content: null,
mediaUri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
if (!repairAttemptedByMessageId.current.has(message.id)) {
repairAttemptedByMessageId.current.add(message.id);
await reencryptPendingMessages();
}
// Fetch the latest keys_metadata and retry once locally.
const { data: refreshed } = await supabase
.from('messages')
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
.eq('id', message.id)
.maybeSingle();
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
const refreshedIv = refreshed?.encryption_iv ?? null;
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
try {
const decryptedContent = await decryptTextMessage(
refreshedEncryptedContent,
refreshedIv,
refreshedMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
true, // isRecipient
);
setState({
content: decryptedContent,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: undefined,
});
return;
} catch {
// Still pending (or repair failed); show the placeholder instead of a hard error.
}
}
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
return;
}
if (shouldAttemptStaleKeyRepair) {
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting stale key repair...`);
setState({
content: null,
mediaUri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_REPAIRING_STALE_KEY',
});
repairAttemptedByMessageId.current.add(message.id);
try {
const repairResult = await repairStaleKey(message.id);
if (repairResult.success) {
console.log(`[E2EE Decrypt] Message ${message.id}: Stale key repaired, retrying decryption...`);
// Fetch updated message and retry
const { data: refreshed } = await supabase
.from('messages')
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
.eq('id', message.id)
.maybeSingle();
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
const refreshedIv = refreshed?.encryption_iv ?? null;
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
const decryptedContent = await decryptTextMessage(
refreshedEncryptedContent,
refreshedIv,
refreshedMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
!isMe, // isRecipient
);
setState({
content: decryptedContent,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: undefined,
});
return;
}
} else {
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair failed:`, repairResult.error);
}
} catch (repairErr) {
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair threw:`, repairErr);
}
// Repair failed or retry failed
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Failed to load message'),
errorCode: 'E2EE_DECRYPT_FAILED',
});
return;
}
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Failed to load message'),
errorCode: 'E2EE_DECRYPT_FAILED',
});
}
}
}, [
message.id,
message.version,
message.content,
message.encrypted_content,
message.encryption_iv,
message.keys_metadata,
message.user_id,
currentUserId,
privateKeyJwk,
hasKeys,
keysLoading,
retryNonce,
]);
useEffect(() => {
decrypt();
}, [decrypt, retryNonce]);
return { ...state, retry };
}
+126
View File
@@ -0,0 +1,126 @@
/**
* useEncryptedSend Hook
*
* Hook for sending encrypted messages via E2EE.
* Handles encryption with triple-wrapped keys (sender, recipient, admin).
*
* IMPORTANT: This hook reads keys from the global Zustand store to ensure
* consistency with all other encryption operations.
*/
import { useCallback, useState } from 'react';
import { Platform } from 'react-native';
import {
encryptTextMessage,
getAdminPublicKey,
getAdminKeyId,
} from '../lib/encryption';
import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption';
import { useEncryptionKeys } from './useEncryptionKeys';
import { useAuthStore } from '../store';
interface UseEncryptedSendReturn {
/** Encrypt a text message for sending */
encryptMessage: (plaintext: string) => Promise<EncryptedMessagePayload | null>;
/** Whether E2EE is available (keys loaded, not on web) */
isE2EEAvailable: boolean;
/** Whether encryption is currently in progress */
isEncrypting: boolean;
/** Last encryption error, if any */
error: Error | null;
}
/**
* Hook for encrypting messages before sending
*
* Returns null if E2EE is not available (web platform, no keys).
* In that case, the caller should send plaintext (v1) messages.
*/
export function useEncryptedSend(): UseEncryptedSendReturn {
const [isEncrypting, setIsEncrypting] = useState(false);
const [error, setError] = useState<Error | null>(null);
const { publicKeyJwk, hasKeys, isLoading } = useEncryptionKeys();
const refreshPartner = useAuthStore((s) => s.refreshPartner);
// E2EE is available if:
// - Not on web platform
// - Keys are loaded and available
const isE2EEAvailable =
Platform.OS !== 'web' && hasKeys && !isLoading && !!publicKeyJwk;
const encryptMessage = useCallback(
async (plaintext: string): Promise<EncryptedMessagePayload | null> => {
// Return null if E2EE not available - caller will send plaintext
if (!isE2EEAvailable) {
console.log('[E2EE Send] E2EE not available, will send plaintext');
return null;
}
// CRITICAL: Read keys directly from the global store at encryption time
// This ensures we always use the most current keys, not potentially stale hook state
const currentKeys = useAuthStore.getState().encryptionKeys;
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk) {
console.warn('[E2EE Send] No keys available in global store at encryption time');
return null;
}
const senderPublicKey = currentKeys.publicKeyJwk;
console.log(
'[E2EE Send] Using sender public key from global store, version:',
currentKeys.keysVersion
);
setIsEncrypting(true);
setError(null);
try {
// Always refresh partner's profile to get their latest public key
// This ensures we don't miss their key if they just generated it
console.log('[E2EE Send] Refreshing partner profile to get latest public key...');
const freshPartner = await refreshPartner();
// Get admin key for moderation access
const adminPublicKey = await getAdminPublicKey();
const adminKeyId = await getAdminKeyId();
// Get partner's public key from the freshly fetched profile
const partnerPublicKey = freshPartner?.public_key_jwk as
| RSAPublicKeyJWK
| null
| undefined;
console.log('[E2EE Send] Partner public key available:', !!partnerPublicKey);
// Encrypt the message using the key from global store
const encryptedPayload = await encryptTextMessage(
plaintext,
senderPublicKey as RSAPublicKeyJWK,
partnerPublicKey ?? null,
adminPublicKey,
adminKeyId
);
console.log('[E2EE Send] Message encrypted successfully');
setIsEncrypting(false);
return encryptedPayload;
} catch (err) {
console.error('[E2EE Send] Failed to encrypt message:', err);
setError(err as Error);
setIsEncrypting(false);
return null;
}
},
[isE2EEAvailable, refreshPartner]
);
return {
encryptMessage,
isE2EEAvailable,
isEncrypting,
error,
};
}
+325
View File
@@ -0,0 +1,325 @@
/**
* useEncryptionKeys Hook
*
* React hook for managing E2EE encryption keys.
* Uses global Zustand store to ensure all components share the same key state.
*
* IMPORTANT: This hook reads from and writes to the global authStore.encryptionKeys
* to prevent race conditions where different hook instances have different state.
*/
import { useCallback, useRef, useEffect } from 'react';
import { Platform } from 'react-native';
import {
generateAndStoreKeyPair,
getPrivateKey,
getLocalPublicKey,
hasKeyPair,
isPublicKeyUploaded,
markPublicKeyUploaded,
isValidPublicKeyJwk,
sanitizePublicKeyJwk,
} from '../lib/encryption';
import type { RSAPublicKeyJWK } from '../lib/encryption';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../store';
interface UseEncryptionKeysReturn {
/** RSA private key in JWK format */
privateKeyJwk: RSAPublicKeyJWK | null;
/** RSA public key in JWK format */
publicKeyJwk: RSAPublicKeyJWK | null;
/** Whether keys are currently being loaded */
isLoading: boolean;
/** Whether valid keys exist */
hasKeys: boolean;
/** Any error that occurred */
error: Error | null;
/** Initialize keys and upload to Supabase - does everything in one call */
initializeAndUploadKeys: () => Promise<{ wasRegenerated: boolean }>;
/** Refresh the key state from storage */
refresh: () => Promise<void>;
}
/**
* Hook for accessing and managing encryption keys.
*
* All key state is stored in the global Zustand store to ensure
* all components see the same keys at all times.
*/
export function useEncryptionKeys(): UseEncryptionKeysReturn {
// Get global state and actions from Zustand store
const encryptionKeys = useAuthStore((s) => s.encryptionKeys);
const setEncryptionKeys = useAuthStore((s) => s.setEncryptionKeys);
const user = useAuthStore((s) => s.user);
// Track if we've already loaded keys in this session
const hasLoadedRef = useRef(false);
const isLoadingRef = useRef(false);
/**
* Load existing keys from SecureStore into global state
*/
const loadKeys = useCallback(async () => {
// E2EE not supported on web
if (Platform.OS === 'web') {
setEncryptionKeys({
privateKeyJwk: null,
publicKeyJwk: null,
isLoadingKeys: false,
hasKeys: false,
keysError: null,
});
return null;
}
// Prevent concurrent loads
if (isLoadingRef.current) {
return encryptionKeys.publicKeyJwk;
}
try {
isLoadingRef.current = true;
setEncryptionKeys({ isLoadingKeys: true, keysError: null });
const keysExist = await hasKeyPair();
if (keysExist) {
const [privateKey, publicKey] = await Promise.all([
getPrivateKey(),
getLocalPublicKey(),
]);
console.log('[E2EE Global] Keys loaded from storage');
setEncryptionKeys({
privateKeyJwk: privateKey,
publicKeyJwk: publicKey,
isLoadingKeys: false,
hasKeys: true,
keysError: null,
});
hasLoadedRef.current = true;
return publicKey;
} else {
console.log('[E2EE Global] No keys in storage');
setEncryptionKeys({
privateKeyJwk: null,
publicKeyJwk: null,
isLoadingKeys: false,
hasKeys: false,
keysError: null,
});
return null;
}
} catch (error) {
console.error('[E2EE Global] Failed to load encryption keys:', error);
setEncryptionKeys({
privateKeyJwk: null,
publicKeyJwk: null,
isLoadingKeys: false,
hasKeys: false,
keysError: error as Error,
});
return null;
} finally {
isLoadingRef.current = false;
}
}, [encryptionKeys.publicKeyJwk, setEncryptionKeys]);
/**
* Initialize encryption keys and upload to Supabase in one operation.
* This avoids race conditions between state updates and upload.
*
* @returns { wasRegenerated: boolean } - true if keys were newly generated or regenerated
*/
const initializeAndUploadKeys = useCallback(async (): Promise<{
wasRegenerated: boolean;
}> => {
if (Platform.OS === 'web') {
console.warn('[E2EE Global] E2EE not supported on web');
return { wasRegenerated: false };
}
if (!user?.id) {
console.warn('[E2EE Global] Cannot initialize keys: user not authenticated');
return { wasRegenerated: false };
}
// Prevent concurrent initialization
if (isLoadingRef.current) {
console.log('[E2EE Global] Key initialization already in progress');
return { wasRegenerated: false };
}
try {
isLoadingRef.current = true;
setEncryptionKeys({ isLoadingKeys: true, keysError: null });
let publicKey: RSAPublicKeyJWK | null = null;
let privateKey = null;
let wasRegenerated = false;
const keysExist = await hasKeyPair();
if (!keysExist) {
// Generate new key pair
console.log('[E2EE Global] Generating new key pair...');
publicKey = await generateAndStoreKeyPair();
privateKey = await getPrivateKey();
wasRegenerated = true;
// Update global state IMMEDIATELY with new keys
setEncryptionKeys({
privateKeyJwk: privateKey,
publicKeyJwk: publicKey,
isLoadingKeys: false,
hasKeys: true,
keysError: null,
});
console.log('[E2EE Global] Key pair generated and stored in global state');
} else {
// Load existing keys
[privateKey, publicKey] = await Promise.all([
getPrivateKey(),
getLocalPublicKey(),
]);
// Update global state with loaded keys
setEncryptionKeys({
privateKeyJwk: privateKey,
publicKeyJwk: publicKey,
isLoadingKeys: false,
hasKeys: true,
keysError: null,
});
}
// Validate keys
if (!publicKey || !isValidPublicKeyJwk(publicKey)) {
if (publicKey) {
console.warn(
'[E2EE Global] Local public key invalid, regenerating...'
);
} else {
console.warn(
'[E2EE Global] Local public key missing, regenerating...'
);
}
publicKey = await generateAndStoreKeyPair();
privateKey = await getPrivateKey();
wasRegenerated = true;
// Update global state IMMEDIATELY with regenerated keys
setEncryptionKeys({
privateKeyJwk: privateKey,
publicKeyJwk: publicKey,
isLoadingKeys: false,
hasKeys: true,
keysError: null,
});
console.log('[E2EE Global] Keys regenerated and stored in global state');
}
// Fetch the server copy to verify validity and avoid stale uploads
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('public_key_jwk')
.eq('id', user.id)
.maybeSingle();
if (profileError) {
throw profileError;
}
const remoteKey = (profile?.public_key_jwk ?? null) as RSAPublicKeyJWK | null;
const remoteValid = isValidPublicKeyJwk(remoteKey);
const localValid = isValidPublicKeyJwk(publicKey);
// Sanitize both keys before comparing (local key may have trailing dots)
const sanitizedLocal = sanitizePublicKeyJwk(publicKey!);
const sanitizedRemote = remoteKey ? sanitizePublicKeyJwk(remoteKey) : null;
const sameKey =
remoteValid &&
localValid &&
sanitizedRemote?.n === sanitizedLocal.n &&
sanitizedRemote?.e === sanitizedLocal.e;
// Upload if remote key is missing/invalid or doesn't match local.
// If we just regenerated the key, we MUST upload it regardless
if ((publicKey && localValid && !sameKey) || (publicKey && wasRegenerated)) {
console.log('[E2EE Global] Uploading public key to profile...');
// Sanitize key before upload
const sanitizedKey = sanitizePublicKeyJwk(publicKey);
const { error } = await supabase
.from('profiles')
.update({
public_key_jwk: sanitizedKey as unknown as Record<string, unknown>,
})
.eq('id', user.id);
if (error) {
throw error;
}
await markPublicKeyUploaded();
console.log('[E2EE Global] Public key uploaded successfully');
} else if (sameKey) {
const alreadyUploaded = await isPublicKeyUploaded();
if (!alreadyUploaded) {
await markPublicKeyUploaded();
}
console.log('[E2EE Global] Public key already uploaded and valid');
}
hasLoadedRef.current = true;
return { wasRegenerated };
} catch (error) {
console.error('[E2EE Global] Failed to initialize encryption keys:', error);
setEncryptionKeys({
isLoadingKeys: false,
keysError: error as Error,
});
throw error;
} finally {
isLoadingRef.current = false;
}
}, [user?.id, setEncryptionKeys]);
/**
* Refresh keys from storage
*/
const refresh = useCallback(async () => {
hasLoadedRef.current = false;
await loadKeys();
}, [loadKeys]);
// Load keys on mount if not already loaded
useEffect(() => {
if (!hasLoadedRef.current && !isLoadingRef.current && !encryptionKeys.hasKeys) {
loadKeys();
}
}, [loadKeys, encryptionKeys.hasKeys]);
// Reset loaded flag when user changes
useEffect(() => {
if (!user?.id) {
hasLoadedRef.current = false;
}
}, [user?.id]);
return {
privateKeyJwk: encryptionKeys.privateKeyJwk,
publicKeyJwk: encryptionKeys.publicKeyJwk,
isLoading: encryptionKeys.isLoadingKeys,
hasKeys: encryptionKeys.hasKeys,
error: encryptionKeys.keysError,
initializeAndUploadKeys,
refresh,
};
}
+61
View File
@@ -0,0 +1,61 @@
import { useState } from 'react';
import { Alert } from 'react-native';
import * as MediaLibrary from 'expo-media-library';
import * as FileSystem from 'expo-file-system';
export function useMediaSaver() {
const [saving, setSaving] = useState(false);
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions();
const saveMedia = async (uri: string, type: 'image' | 'video') => {
try {
setSaving(true);
// Check permissions
if (!permissionResponse?.granted) {
const { granted } = await requestPermission();
if (!granted) {
Alert.alert(
'Permission required',
'Please allow access to your photo library to save media.'
);
setSaving(false);
return;
}
}
// Handle remote URIs
let fileUri = uri;
if (uri.startsWith('http') || uri.startsWith('https')) {
const extension = type === 'video' ? '.mp4' : '.jpg';
const fileName = `download_${Date.now()}${extension}`;
const downloadRes = await FileSystem.downloadAsync(
uri,
FileSystem.cacheDirectory + fileName
);
fileUri = downloadRes.uri;
}
// Save to library
await MediaLibrary.createAssetAsync(fileUri);
Alert.alert('Saved', `${type === 'video' ? 'Video' : 'Image'} saved to your gallery.`);
// Cleanup if we downloaded
if (uri !== fileUri) {
await FileSystem.deleteAsync(fileUri, { idempotent: true });
}
} catch (error) {
console.error('Error saving media:', error);
Alert.alert('Error', 'Failed to save media.');
} finally {
setSaving(false);
}
};
return {
saveMedia,
saving,
};
}
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { Database } from '../types/supabase'; import { Database } from '../types/supabase';
@@ -14,7 +14,7 @@ export interface UseMessageSubscriptionConfig {
} }
export interface UseMessageSubscriptionReturn { export interface UseMessageSubscriptionReturn {
/** Array of messages, sorted newest first */ /** Array of messages, sorted newest first (filtered by user's deletions) */
messages: Message[]; messages: Message[];
/** Set messages externally (e.g., for optimistic updates) */ /** Set messages externally (e.g., for optimistic updates) */
setMessages: React.Dispatch<React.SetStateAction<Message[]>>; setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
@@ -36,10 +36,65 @@ export const useMessageSubscription = (
): UseMessageSubscriptionReturn => { ): UseMessageSubscriptionReturn => {
const { matchId, userId, onNewMessage } = config; const { matchId, userId, onNewMessage } = config;
const [messages, setMessages] = useState<Message[]>([]); const [allMessages, setAllMessages] = useState<Message[]>([]);
const [deletedMessageIds, setDeletedMessageIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const isFocusedRef = useRef(false); const isFocusedRef = useRef(false);
// Filter out messages that the user has deleted for themselves
const messages = useMemo(() => {
return allMessages.filter(m => !deletedMessageIds.has(m.id));
}, [allMessages, deletedMessageIds]);
// Wrapper to update allMessages (for external use like optimistic updates)
const setMessages: React.Dispatch<React.SetStateAction<Message[]>> = useCallback((updater) => {
setAllMessages(updater);
}, []);
// Fetch user's deleted message IDs
useEffect(() => {
if (!userId) return;
const fetchDeletions = async () => {
const { data } = await supabase
.from('message_deletions')
.select('message_id')
.eq('user_id', userId);
if (data) {
setDeletedMessageIds(new Set(data.map(d => d.message_id)));
}
};
fetchDeletions();
}, [userId]);
// Subscribe to user's deletions for real-time sync across devices
useEffect(() => {
if (!userId) return;
const channel = supabase
.channel(`deletions:${userId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'message_deletions',
filter: `user_id=eq.${userId}`,
},
(payload) => {
const deletion = payload.new as { message_id: string };
setDeletedMessageIds(prev => new Set([...prev, deletion.message_id]));
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [userId]);
// Fetch messages and mark as read // Fetch messages and mark as read
useEffect(() => { useEffect(() => {
if (!matchId || !userId) { if (!matchId || !userId) {
@@ -57,7 +112,7 @@ export const useMessageSubscription = (
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (data) { if (data) {
setMessages(data); setAllMessages(data);
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -73,7 +128,7 @@ export const useMessageSubscription = (
.update({ delivered_at: now, read_at: now }) .update({ delivered_at: now, read_at: now })
.in('id', unreadIds); .in('id', unreadIds);
setMessages(prev => prev.map(m => setAllMessages(prev => prev.map(m =>
unreadIds.includes(m.id) ? { ...m, delivered_at: now, read_at: now } : m unreadIds.includes(m.id) ? { ...m, delivered_at: now, read_at: now } : m
)); ));
} else if (undeliveredIds.length > 0) { } else if (undeliveredIds.length > 0) {
@@ -83,7 +138,7 @@ export const useMessageSubscription = (
.update({ delivered_at: now }) .update({ delivered_at: now })
.in('id', undeliveredIds); .in('id', undeliveredIds);
setMessages(prev => prev.map(m => setAllMessages(prev => prev.map(m =>
undeliveredIds.includes(m.id) ? { ...m, delivered_at: now } : m undeliveredIds.includes(m.id) ? { ...m, delivered_at: now } : m
)); ));
} }
@@ -123,7 +178,7 @@ export const useMessageSubscription = (
newMessage.read_at = now; newMessage.read_at = now;
} }
setMessages(prev => [newMessage, ...prev]); setAllMessages(prev => [newMessage, ...prev]);
onNewMessage?.(); onNewMessage?.();
} }
) )
@@ -137,7 +192,7 @@ export const useMessageSubscription = (
}, },
(payload) => { (payload) => {
const updatedMessage = payload.new as Message; const updatedMessage = payload.new as Message;
setMessages(prev => setAllMessages(prev =>
prev.map(m => m.id === updatedMessage.id ? updatedMessage : m) prev.map(m => m.id === updatedMessage.id ? updatedMessage : m)
); );
} }
@@ -0,0 +1,88 @@
/**
* Admin Keys Management
*
* Fetches the active admin public key from the master_keys table.
*/
import { supabase } from '../supabase';
import type { MasterKey, RSAPublicKeyJWK } from './types';
// Cache for admin key to avoid repeated database calls
let cachedAdminKey: MasterKey | null = null;
let cacheTimestamp: number = 0;
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Fetch the active admin public key from the database
*
* Results are cached for 5 minutes to reduce database calls.
*
* @param forceRefresh - Force a fresh fetch, bypassing cache
* @returns The active master key record
*/
export async function getActiveMasterKey(forceRefresh = false): Promise<MasterKey> {
const now = Date.now();
// Return cached key if still valid
if (!forceRefresh && cachedAdminKey && (now - cacheTimestamp) < CACHE_TTL_MS) {
return cachedAdminKey;
}
const { data, error } = await supabase
.from('master_keys')
.select('*')
.eq('is_active', true)
.limit(1)
.maybeSingle();
if (error) {
throw new Error(`Failed to fetch admin key: ${error.message}`);
}
if (!data) {
throw new Error('No active admin key found. E2EE is not configured.');
}
// Validate the key structure
const publicKeyJwk = data.public_key_jwk as RSAPublicKeyJWK;
if (!publicKeyJwk || publicKeyJwk.kty !== 'RSA') {
throw new Error('Invalid admin public key format');
}
cachedAdminKey = {
id: data.id,
key_name: data.key_name,
public_key_jwk: publicKeyJwk,
is_active: data.is_active ?? true,
created_at: data.created_at ?? '',
rotated_at: data.rotated_at,
};
cacheTimestamp = now;
return cachedAdminKey;
}
/**
* Get the admin public key JWK directly
*/
export async function getAdminPublicKey(): Promise<RSAPublicKeyJWK> {
const masterKey = await getActiveMasterKey();
return masterKey.public_key_jwk;
}
/**
* Get the admin key ID for storing in message metadata
*/
export async function getAdminKeyId(): Promise<string> {
const masterKey = await getActiveMasterKey();
return masterKey.id;
}
/**
* Clear the admin key cache
* Called when keys might have been rotated
*/
export function clearAdminKeyCache(): void {
cachedAdminKey = null;
cacheTimestamp = 0;
}
@@ -0,0 +1,30 @@
/**
* E2EE Constants
*
* Algorithm identifiers and storage key names for encryption
*/
// SecureStore key names for private key storage
export const PRIVATE_KEY_PREFIX = 'e2ee_private_key_chunk_';
export const PUBLIC_KEY_UPLOADED = 'e2ee_public_key_uploaded';
export const LOCAL_PUBLIC_KEY = 'e2ee_local_public_key';
// Encryption algorithms
export const SYMMETRIC_ALGORITHM = 'AES-GCM';
export const SYMMETRIC_KEY_LENGTH = 256;
export const IV_LENGTH_BYTES = 12; // 96 bits for AES-GCM
// Key wrapping
export const KEY_WRAP_ALGORITHM = 'RSA-OAEP';
export const KEY_WRAP_HASH = 'SHA-256';
export const RSA_KEY_SIZE = 2048;
// SecureStore chunking (RSA-2048 JWK is ~1.7KB, SecureStore limit is 2KB)
export const SECURE_STORE_CHUNK_SIZE = 1900;
// Default master key name
export const DEFAULT_MASTER_KEY_NAME = 'e2ee_admin_key_v1';
// Message version constants
export const MESSAGE_VERSION_PLAINTEXT = 1;
export const MESSAGE_VERSION_E2EE = 2;
+171
View File
@@ -0,0 +1,171 @@
/**
* Low-level Crypto Operations
*
* AES-256-GCM encryption/decryption and RSA key wrapping operations.
*/
import {
SYMMETRIC_ALGORITHM,
SYMMETRIC_KEY_LENGTH,
IV_LENGTH_BYTES,
KEY_WRAP_ALGORITHM
} from './constants';
import { arrayBufferToBase64, base64ToArrayBuffer } from './utils';
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from './types';
import { importPublicKey, importPrivateKey } from './keyManager';
/**
* Get the Web Crypto API
*/
function getSubtle(): SubtleCrypto {
if (typeof crypto !== 'undefined' && crypto.subtle) {
return crypto.subtle;
}
throw new Error('Web Crypto API not available');
}
/**
* Get crypto.getRandomValues
*/
function getRandomValues(array: Uint8Array): Uint8Array {
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
return crypto.getRandomValues(array);
}
throw new Error('Crypto random not available');
}
/**
* Generate a random AES-256 key
*/
export async function generateAESKey(): Promise<CryptoKey> {
const subtle = getSubtle();
return await subtle.generateKey(
{ name: SYMMETRIC_ALGORITHM, length: SYMMETRIC_KEY_LENGTH },
true,
['encrypt', 'decrypt']
);
}
/**
* Generate a random initialization vector for AES-GCM (12 bytes)
*/
export function generateIV(): Uint8Array {
return getRandomValues(new Uint8Array(IV_LENGTH_BYTES));
}
/**
* Encrypt data with AES-256-GCM
*
* @param plaintext - The data to encrypt (string or ArrayBuffer)
* @param key - The AES key
* @param iv - The initialization vector
* @returns The encrypted ciphertext as ArrayBuffer
*/
export async function encryptAES(
plaintext: string | ArrayBuffer,
key: CryptoKey,
iv: Uint8Array
): Promise<ArrayBuffer> {
const subtle = getSubtle();
const data = typeof plaintext === 'string'
? new TextEncoder().encode(plaintext)
: plaintext;
return await subtle.encrypt(
{ name: SYMMETRIC_ALGORITHM, iv: iv as BufferSource },
key,
data
);
}
/**
* Decrypt data with AES-256-GCM
*
* @param ciphertext - The encrypted data
* @param key - The AES key
* @param iv - The initialization vector
* @returns The decrypted plaintext as ArrayBuffer
*/
export async function decryptAES(
ciphertext: ArrayBuffer,
key: CryptoKey,
iv: Uint8Array
): Promise<ArrayBuffer> {
const subtle = getSubtle();
return await subtle.decrypt(
{ name: SYMMETRIC_ALGORITHM, iv: iv as BufferSource },
key,
ciphertext
);
}
/**
* Export an AES key to raw bytes
*/
export async function exportAESKey(key: CryptoKey): Promise<ArrayBuffer> {
const subtle = getSubtle();
return await subtle.exportKey('raw', key);
}
/**
* Import an AES key from raw bytes
*/
export async function importAESKey(rawKey: ArrayBuffer): Promise<CryptoKey> {
const subtle = getSubtle();
return await subtle.importKey(
'raw',
rawKey,
{ name: SYMMETRIC_ALGORITHM },
false,
['decrypt']
);
}
/**
* Wrap (encrypt) an AES key with an RSA public key
*
* @param aesKeyRaw - The raw AES key bytes
* @param publicKeyJwk - The recipient's RSA public key
* @returns The wrapped key as base64 string
*/
export async function wrapAESKey(
aesKeyRaw: ArrayBuffer,
publicKeyJwk: RSAPublicKeyJWK
): Promise<string> {
const subtle = getSubtle();
const rsaKey = await importPublicKey(publicKeyJwk);
const wrappedKey = await subtle.encrypt(
{ name: KEY_WRAP_ALGORITHM },
rsaKey,
aesKeyRaw
);
return arrayBufferToBase64(wrappedKey);
}
/**
* Unwrap (decrypt) an AES key with an RSA private key
*
* @param wrappedKeyBase64 - The wrapped key as base64 string
* @param privateKeyJwk - The user's RSA private key
* @returns The unwrapped AES key as ArrayBuffer
*/
export async function unwrapAESKey(
wrappedKeyBase64: string,
privateKeyJwk: RSAPrivateKeyJWK
): Promise<ArrayBuffer> {
const subtle = getSubtle();
const rsaKey = await importPrivateKey(privateKeyJwk);
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
return await subtle.decrypt(
{ name: KEY_WRAP_ALGORITHM },
rsaKey,
wrappedKey
);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* E2EE Encryption Library
*
* Public API exports for the Sauci E2EE implementation.
*/
// Types
export type {
KeysMetadata,
EncryptedMessagePayload,
EncryptedMediaPayload,
DecryptedContent,
DecryptedMessageState,
RSAPublicKeyJWK,
RSAPrivateKeyJWK,
EncryptionKeysState,
MasterKey,
EncryptionContext,
} from './types';
// Constants
export {
MESSAGE_VERSION_PLAINTEXT,
MESSAGE_VERSION_E2EE,
} from './constants';
// Key Management
export {
generateAndStoreKeyPair,
getPrivateKey,
getLocalPublicKey,
hasKeyPair,
isPublicKeyUploaded,
markPublicKeyUploaded,
clearKeys,
isCryptoAvailable,
isValidPublicKeyJwk,
sanitizePublicKeyJwk,
} from './keyManager';
// Text Message Encryption/Decryption
export { encryptTextMessage } from './messageEncryption';
export { decryptTextMessage } from './messageDecryption';
// Media Encryption/Decryption
export { encryptMediaFile, getEncryptedFileExtension } from './mediaEncryption';
export { decryptMediaFile, cleanupDecryptedMedia } from './mediaDecryption';
// Admin Keys
export {
getActiveMasterKey,
getAdminPublicKey,
getAdminKeyId,
clearAdminKeyCache,
} from './adminKeys';
// Utilities
export {
arrayBufferToBase64,
base64ToArrayBuffer,
} from './utils';
// Key Rotation
export { triggerKeyRotation } from './triggerKeyRotation';
// Stale Key Repair
export { repairStaleKey } from './repairStaleKey';
@@ -0,0 +1,294 @@
/**
* E2EE Key Manager
*
* Handles RSA key pair generation and secure storage using expo-secure-store.
* Keys are stored in chunks due to SecureStore's 2KB limit.
*/
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
import {
PRIVATE_KEY_PREFIX,
PUBLIC_KEY_UPLOADED,
LOCAL_PUBLIC_KEY,
SECURE_STORE_CHUNK_SIZE,
RSA_KEY_SIZE,
KEY_WRAP_ALGORITHM,
KEY_WRAP_HASH
} from './constants';
import { chunkString } from './utils';
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from './types';
/**
* Check if we're running in a web environment
*/
function isWeb(): boolean {
return Platform.OS === 'web';
}
/**
* Check if crypto is available (polyfill installed and native modules built)
*/
export function isCryptoAvailable(): boolean {
if (isWeb()) return false;
return typeof crypto !== 'undefined' && crypto.subtle !== undefined;
}
/**
* Sanitize a public key by removing trailing dots that may appear
* due to react-native-quick-crypto encoding issues.
*/
export function sanitizePublicKeyJwk(publicKeyJwk: RSAPublicKeyJWK): RSAPublicKeyJWK {
return {
...publicKeyJwk,
n: publicKeyJwk.n.replace(/\.+$/, ''),
e: publicKeyJwk.e.replace(/\.+$/, ''),
};
}
/**
* Validate that a public key JWK looks well-formed for RSA import.
*/
export function isValidPublicKeyJwk(publicKeyJwk: RSAPublicKeyJWK | null): boolean {
if (!publicKeyJwk) return false;
if (publicKeyJwk.kty !== 'RSA') return false;
if (typeof publicKeyJwk.n !== 'string' || typeof publicKeyJwk.e !== 'string') return false;
// Sanitize before validation (handle trailing dots from quick-crypto)
const sanitized = sanitizePublicKeyJwk(publicKeyJwk);
const base64UrlPattern = /^[A-Za-z0-9_\-+/=]+$/;
return base64UrlPattern.test(sanitized.n) && base64UrlPattern.test(sanitized.e);
}
/**
* Get the Web Crypto API
* In React Native with react-native-quick-crypto, it's available globally
*/
function getSubtle(): SubtleCrypto {
// react-native-quick-crypto polyfills global.crypto
if (typeof crypto !== 'undefined' && crypto.subtle) {
return crypto.subtle;
}
throw new Error('Web Crypto API not available. Run: npx expo prebuild --clean');
}
/**
* Generate a new RSA-2048 key pair for E2EE
*
* @returns The public key in JWK format (private key is stored in SecureStore)
*/
export async function generateAndStoreKeyPair(): Promise<RSAPublicKeyJWK> {
if (isWeb()) {
console.warn('E2EE key generation not supported on web platform');
throw new Error('E2EE is not supported on web');
}
const subtle = getSubtle();
// Generate RSA-2048 key pair
const keyPair = await subtle.generateKey(
{
name: KEY_WRAP_ALGORITHM,
modulusLength: RSA_KEY_SIZE,
publicExponent: new Uint8Array([1, 0, 1]), // 65537
hash: KEY_WRAP_HASH,
},
true, // extractable
['encrypt', 'decrypt']
);
// Export keys as JWK
const publicKeyJwk = await subtle.exportKey('jwk', keyPair.publicKey) as RSAPublicKeyJWK;
const privateKeyJwk = await subtle.exportKey('jwk', keyPair.privateKey) as RSAPrivateKeyJWK;
// Store private key in SecureStore (chunked)
await storePrivateKey(privateKeyJwk);
// Store public key locally for quick access
await storeLocalPublicKey(publicKeyJwk);
return publicKeyJwk;
}
/**
* Store private key in SecureStore using chunked storage
* RSA-2048 JWK is ~1.7KB, SecureStore limit is 2KB
*/
async function storePrivateKey(privateKeyJwk: RSAPrivateKeyJWK): Promise<void> {
const json = JSON.stringify(privateKeyJwk);
const chunks = chunkString(json, SECURE_STORE_CHUNK_SIZE);
// Store chunk count
await SecureStore.setItemAsync(
`${PRIVATE_KEY_PREFIX}count`,
chunks.length.toString()
);
// Store each chunk with high security settings
for (let i = 0; i < chunks.length; i++) {
await SecureStore.setItemAsync(
`${PRIVATE_KEY_PREFIX}${i}`,
chunks[i],
{
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
);
}
}
/**
* Store public key locally for quick access during encryption
*/
async function storeLocalPublicKey(publicKeyJwk: RSAPublicKeyJWK): Promise<void> {
await SecureStore.setItemAsync(
LOCAL_PUBLIC_KEY,
JSON.stringify(publicKeyJwk)
);
}
/**
* Retrieve the private key from SecureStore
*
* @returns The private key in JWK format, or null if not found
*/
export async function getPrivateKey(): Promise<RSAPrivateKeyJWK | null> {
if (isWeb()) {
return null;
}
try {
const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`);
if (!countStr) return null;
const count = parseInt(countStr, 10);
const chunks: string[] = [];
for (let i = 0; i < count; i++) {
const chunk = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}${i}`);
if (!chunk) return null;
chunks.push(chunk);
}
return JSON.parse(chunks.join('')) as RSAPrivateKeyJWK;
} catch (error) {
console.error('Failed to retrieve private key:', error);
return null;
}
}
/**
* Retrieve the locally stored public key
*
* @returns The public key in JWK format, or null if not found
*/
export async function getLocalPublicKey(): Promise<RSAPublicKeyJWK | null> {
if (isWeb()) {
return null;
}
try {
const json = await SecureStore.getItemAsync(LOCAL_PUBLIC_KEY);
if (!json) return null;
return JSON.parse(json) as RSAPublicKeyJWK;
} catch (error) {
console.error('Failed to retrieve local public key:', error);
return null;
}
}
/**
* Check if a key pair exists in SecureStore
*/
export async function hasKeyPair(): Promise<boolean> {
if (isWeb()) {
return false;
}
try {
const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`);
return countStr !== null;
} catch (error) {
console.error('Failed to check for key pair:', error);
return false;
}
}
/**
* Check if public key has been uploaded to Supabase
*/
export async function isPublicKeyUploaded(): Promise<boolean> {
if (isWeb()) {
return false;
}
try {
const uploaded = await SecureStore.getItemAsync(PUBLIC_KEY_UPLOADED);
return uploaded === 'true';
} catch (error) {
console.error('Failed to check public key upload status:', error);
return false;
}
}
/**
* Mark public key as uploaded to Supabase
*/
export async function markPublicKeyUploaded(): Promise<void> {
await SecureStore.setItemAsync(PUBLIC_KEY_UPLOADED, 'true');
}
/**
* Clear all encryption keys from SecureStore
* Used when user logs out or leaves a couple
*/
export async function clearKeys(): Promise<void> {
if (isWeb()) {
return;
}
try {
const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`);
if (countStr) {
const count = parseInt(countStr, 10);
for (let i = 0; i < count; i++) {
await SecureStore.deleteItemAsync(`${PRIVATE_KEY_PREFIX}${i}`);
}
await SecureStore.deleteItemAsync(`${PRIVATE_KEY_PREFIX}count`);
}
await SecureStore.deleteItemAsync(PUBLIC_KEY_UPLOADED);
await SecureStore.deleteItemAsync(LOCAL_PUBLIC_KEY);
} catch (error) {
console.error('Failed to clear encryption keys:', error);
}
}
/**
* Import an RSA public key from JWK format for encryption operations
*/
export async function importPublicKey(publicKeyJwk: RSAPublicKeyJWK): Promise<CryptoKey> {
const subtle = getSubtle();
return await subtle.importKey(
'jwk',
publicKeyJwk,
{ name: KEY_WRAP_ALGORITHM, hash: KEY_WRAP_HASH },
false,
['encrypt']
);
}
/**
* Import an RSA private key from JWK format for decryption operations
*/
export async function importPrivateKey(privateKeyJwk: RSAPrivateKeyJWK): Promise<CryptoKey> {
const subtle = getSubtle();
return await subtle.importKey(
'jwk',
privateKeyJwk,
{ name: KEY_WRAP_ALGORITHM, hash: KEY_WRAP_HASH },
false,
['decrypt']
);
}
@@ -0,0 +1,95 @@
/**
* Media Decryption
*
* Decrypts E2EE images and videos using a download-first approach.
*/
import * as FileSystem from 'expo-file-system';
import { unwrapAESKey, importAESKey, decryptAES } from './crypto';
import { base64ToArrayBuffer, base64ToUint8Array, arrayBufferToBase64 } from './utils';
import type { KeysMetadata, RSAPrivateKeyJWK } from './types';
/**
* Decrypt an encrypted media file
*
* Downloads the encrypted file, decrypts it, and returns a local file URI.
*
* @param encryptedUrl - Signed URL to the .enc file in storage
* @param encryptionIv - Base64-encoded initialization vector
* @param keysMetadata - The wrapped keys metadata
* @param privateKeyJwk - User's RSA private key
* @param isRecipient - Whether the current user is the recipient
* @param mediaType - Type of media ('image' or 'video')
* @returns Local file URI to the decrypted media
*/
export async function decryptMediaFile(
encryptedUrl: string,
encryptionIv: string,
keysMetadata: KeysMetadata,
privateKeyJwk: RSAPrivateKeyJWK,
isRecipient: boolean,
mediaType: 'image' | 'video'
): Promise<string> {
// 1. Download entire encrypted file
const tempEncryptedPath = `${FileSystem.cacheDirectory}${Date.now()}_encrypted.enc`;
const downloadResult = await FileSystem.downloadAsync(
encryptedUrl,
tempEncryptedPath
);
if (downloadResult.status !== 200) {
throw new Error(`Failed to download encrypted file: ${downloadResult.status}`);
}
// 2. Read encrypted data
const encryptedBase64 = await FileSystem.readAsStringAsync(
downloadResult.uri,
{ encoding: FileSystem.EncodingType.Base64 }
);
const encryptedData = base64ToArrayBuffer(encryptedBase64);
// 3. Unwrap AES key
const wrappedKeyBase64 = isRecipient
? keysMetadata.recipient_wrapped_key
: keysMetadata.sender_wrapped_key;
if (!wrappedKeyBase64) {
throw new Error('No wrapped key available for media decryption');
}
const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk);
const aesKey = await importAESKey(rawAesKey);
// 4. Decrypt file
const iv = base64ToUint8Array(encryptionIv);
const decryptedData = await decryptAES(encryptedData, aesKey, iv);
// 5. Write decrypted file
const ext = mediaType === 'video' ? 'mp4' : 'jpg';
const decryptedUri = `${FileSystem.cacheDirectory}${Date.now()}_decrypted.${ext}`;
await FileSystem.writeAsStringAsync(
decryptedUri,
arrayBufferToBase64(decryptedData),
{ encoding: FileSystem.EncodingType.Base64 }
);
// 6. Clean up encrypted temp file
await FileSystem.deleteAsync(downloadResult.uri, { idempotent: true });
return decryptedUri;
}
/**
* Clean up a decrypted media file from cache
*
* @param uri - Local file URI to delete
*/
export async function cleanupDecryptedMedia(uri: string): Promise<void> {
try {
await FileSystem.deleteAsync(uri, { idempotent: true });
} catch (error) {
console.warn('Failed to clean up decrypted media:', error);
}
}
@@ -0,0 +1,82 @@
/**
* Media Encryption
*
* Encrypts images and videos using AES-256-GCM with triple-wrapped keys.
* Uses a download-then-decrypt approach for simplicity.
*/
import * as FileSystem from 'expo-file-system';
import { generateAESKey, generateIV, encryptAES, exportAESKey, wrapAESKey } from './crypto';
import { arrayBufferToBase64, base64ToArrayBuffer } from './utils';
import type { KeysMetadata, EncryptedMediaPayload, RSAPublicKeyJWK } from './types';
/**
* Encrypt a media file (image or video) for E2EE
*
* @param sourceUri - Local file URI of the media to encrypt
* @param senderPublicKeyJwk - Sender's RSA public key
* @param recipientPublicKeyJwk - Recipient's RSA public key (may be null)
* @param adminPublicKeyJwk - Admin's RSA public key
* @param adminKeyId - UUID of the admin key record
* @returns Encrypted media payload with local file URI
*/
export async function encryptMediaFile(
sourceUri: string,
senderPublicKeyJwk: RSAPublicKeyJWK,
recipientPublicKeyJwk: RSAPublicKeyJWK | null,
adminPublicKeyJwk: RSAPublicKeyJWK,
adminKeyId: string
): Promise<EncryptedMediaPayload> {
// 1. Read entire file into memory as base64
const fileBase64 = await FileSystem.readAsStringAsync(sourceUri, {
encoding: FileSystem.EncodingType.Base64,
});
const fileBuffer = base64ToArrayBuffer(fileBase64);
// 2. Generate AES key and IV
const aesKey = await generateAESKey();
const iv = generateIV();
// 3. Encrypt entire file
const encryptedData = await encryptAES(fileBuffer, aesKey, iv);
// 4. Write encrypted file to temp location
const encryptedUri = `${FileSystem.cacheDirectory}${Date.now()}.enc`;
await FileSystem.writeAsStringAsync(
encryptedUri,
arrayBufferToBase64(encryptedData),
{ encoding: FileSystem.EncodingType.Base64 }
);
// 5. Wrap AES key for all parties
const rawAesKey = await exportAESKey(aesKey);
const senderWrappedKey = await wrapAESKey(rawAesKey, senderPublicKeyJwk);
const adminWrappedKey = await wrapAESKey(rawAesKey, adminPublicKeyJwk);
let recipientWrappedKey: string | undefined;
if (recipientPublicKeyJwk) {
recipientWrappedKey = await wrapAESKey(rawAesKey, recipientPublicKeyJwk);
}
return {
encryptedFileUri: encryptedUri,
encryption_iv: arrayBufferToBase64(iv.buffer as ArrayBuffer),
keys_metadata: {
sender_wrapped_key: senderWrappedKey,
recipient_wrapped_key: recipientWrappedKey,
admin_wrapped_key: adminWrappedKey,
admin_key_id: adminKeyId,
algorithm: 'AES-256-GCM',
key_wrap_algorithm: 'RSA-OAEP-SHA256',
pending_recipient: !recipientPublicKeyJwk,
},
};
}
/**
* Get the file extension for encrypted media
*/
export function getEncryptedFileExtension(): string {
return '.enc';
}
@@ -0,0 +1,52 @@
/**
* Text Message Decryption
*
* Decrypts E2EE text messages using the user's RSA private key.
*/
import { unwrapAESKey, importAESKey, decryptAES } from './crypto';
import { base64ToArrayBuffer, base64ToUint8Array } from './utils';
import type { KeysMetadata, RSAPrivateKeyJWK } from './types';
/**
* Decrypt an E2EE text message
*
* @param encryptedContent - Base64-encoded ciphertext
* @param encryptionIv - Base64-encoded initialization vector
* @param keysMetadata - The wrapped keys metadata
* @param privateKeyJwk - User's RSA private key
* @param isRecipient - Whether the current user is the recipient (true) or sender (false)
* @returns The decrypted plaintext message
*/
export async function decryptTextMessage(
encryptedContent: string,
encryptionIv: string,
keysMetadata: KeysMetadata,
privateKeyJwk: RSAPrivateKeyJWK,
isRecipient: boolean
): Promise<string> {
// 1. Select the correct wrapped key
const wrappedKeyBase64 = isRecipient
? keysMetadata.recipient_wrapped_key
: keysMetadata.sender_wrapped_key;
if (!wrappedKeyBase64) {
throw new Error('No wrapped key available for decryption');
}
// 2. Unwrap AES key using RSA private key
const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk);
// 3. Import AES key
const aesKey = await importAESKey(rawAesKey);
// 4. Decrypt content
const iv = base64ToUint8Array(encryptionIv);
const ciphertext = base64ToArrayBuffer(encryptedContent);
const plaintext = await decryptAES(ciphertext, aesKey, iv);
// 5. Decode to string
return new TextDecoder().decode(plaintext);
}
@@ -0,0 +1,67 @@
/**
* Text Message Encryption
*
* Encrypts text messages using AES-256-GCM with triple-wrapped keys.
*/
import { generateAESKey, generateIV, encryptAES, exportAESKey, wrapAESKey } from './crypto';
import { arrayBufferToBase64 } from './utils';
import type { KeysMetadata, EncryptedMessagePayload, RSAPublicKeyJWK } from './types';
/**
* Encrypt a text message for E2EE
*
* The message is encrypted with a random AES-256 key, and that key is
* wrapped (encrypted) separately for the sender, recipient, and admin.
*
* @param plaintext - The message text to encrypt
* @param senderPublicKeyJwk - Sender's RSA public key (for their own decryption)
* @param recipientPublicKeyJwk - Recipient's RSA public key (may be null if not yet generated)
* @param adminPublicKeyJwk - Admin's RSA public key (for moderation access)
* @param adminKeyId - UUID of the admin key record in master_keys table
* @returns The encrypted message payload
*/
export async function encryptTextMessage(
plaintext: string,
senderPublicKeyJwk: RSAPublicKeyJWK,
recipientPublicKeyJwk: RSAPublicKeyJWK | null,
adminPublicKeyJwk: RSAPublicKeyJWK,
adminKeyId: string
): Promise<EncryptedMessagePayload> {
// 1. Generate random AES-256 key
const aesKey = await generateAESKey();
// 2. Generate random 96-bit IV
const iv = generateIV();
// 3. Encrypt plaintext
const ciphertext = await encryptAES(plaintext, aesKey, iv);
// 4. Export AES key for wrapping
const rawAesKey = await exportAESKey(aesKey);
// 5. Wrap AES key for each party
const senderWrappedKey = await wrapAESKey(rawAesKey, senderPublicKeyJwk);
const adminWrappedKey = await wrapAESKey(rawAesKey, adminPublicKeyJwk);
let recipientWrappedKey: string | undefined;
if (recipientPublicKeyJwk) {
recipientWrappedKey = await wrapAESKey(rawAesKey, recipientPublicKeyJwk);
}
return {
version: 2,
encrypted_content: arrayBufferToBase64(ciphertext),
encryption_iv: arrayBufferToBase64(iv.buffer as ArrayBuffer),
keys_metadata: {
sender_wrapped_key: senderWrappedKey,
recipient_wrapped_key: recipientWrappedKey,
admin_wrapped_key: adminWrappedKey,
admin_key_id: adminKeyId,
algorithm: 'AES-256-GCM',
key_wrap_algorithm: 'RSA-OAEP-SHA256',
pending_recipient: !recipientPublicKeyJwk,
},
};
}
@@ -0,0 +1,77 @@
/**
* Pending Message Re-encryption (Server Assisted)
*
* Triggers the Supabase Edge Function `reencrypt-pending-messages` to re-wrap
* AES keys for messages that were sent before the current user had E2EE keys.
*
* This does NOT decrypt message content on the server. It only re-wraps the
* per-message AES key using the user's public key (admin recovery model).
*/
import { supabase } from '../supabase';
export interface ReencryptPendingMessagesResult {
success: boolean;
updated: number;
reason?: string;
error?: string;
}
let inFlight: Promise<ReencryptPendingMessagesResult | null> | null = null;
let lastRunAtMs = 0;
const MIN_INTERVAL_MS = 15_000;
export async function reencryptPendingMessages(
options: { force?: boolean } = {}
): Promise<ReencryptPendingMessagesResult | null> {
const now = Date.now();
if (inFlight) return inFlight;
if (!options.force && now - lastRunAtMs < MIN_INTERVAL_MS) {
return null;
}
inFlight = (async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
return null;
}
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
if (!baseUrl) {
return { success: false, updated: 0, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
}
const response = await fetch(`${baseUrl}/functions/v1/reencrypt-pending-messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
const text = await response.text();
return { success: false, updated: 0, error: text || 'Failed to re-encrypt pending messages' };
}
const result = (await response.json()) as ReencryptPendingMessagesResult;
lastRunAtMs = Date.now();
return result;
} catch (err) {
return {
success: false,
updated: 0,
error: err instanceof Error ? err.message : 'Unknown error',
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
@@ -0,0 +1,82 @@
/**
* Stale Key Repair (Server Assisted)
*
* When a message was encrypted offline with a recipient's old public key,
* and the recipient has since regenerated their keys, this function requests
* the server to re-wrap the AES key using the admin recovery model.
*/
import { supabase } from '../supabase';
export interface RepairStaleKeyResult {
success: boolean;
message_id?: string;
error?: string;
}
// Track in-flight repairs to avoid duplicate requests
const inFlightRepairs = new Map<string, Promise<RepairStaleKeyResult>>();
/**
* Request the server to repair a message encrypted with a stale key.
*
* @param messageId - The ID of the message to repair
* @returns Result of the repair operation
*/
export async function repairStaleKey(
messageId: string
): Promise<RepairStaleKeyResult> {
// Check if already in flight
const existing = inFlightRepairs.get(messageId);
if (existing) {
return existing;
}
const promise = (async (): Promise<RepairStaleKeyResult> => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
return { success: false, error: 'Not authenticated' };
}
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
if (!baseUrl) {
return { success: false, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
}
const response = await fetch(`${baseUrl}/functions/v1/repair-stale-key`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ message_id: messageId }),
});
if (!response.ok) {
const text = await response.text();
let errorMessage = 'Failed to repair message key';
try {
const json = JSON.parse(text);
errorMessage = json.error || errorMessage;
} catch {
// Use default error message
}
return { success: false, error: errorMessage };
}
const result = (await response.json()) as RepairStaleKeyResult;
return result;
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Unknown error',
};
} finally {
inFlightRepairs.delete(messageId);
}
})();
inFlightRepairs.set(messageId, promise);
return promise;
}
@@ -0,0 +1,72 @@
/**
* Trigger Key Rotation (Server Assisted)
*
* Calls the Supabase Edge Function `handle-key-rotation` to re-wrap
* all message AES keys with the user's current public key.
*
* This is needed when a user regenerates their keys (new device, app reinstall).
* The admin key is used to unwrap the message keys and re-wrap them for
* the user's new public key.
*/
import { supabase } from '../supabase';
export interface KeyRotationResult {
success: boolean;
updated: number;
reason?: string;
error?: string;
}
let inFlight: Promise<KeyRotationResult | null> | null = null;
export async function triggerKeyRotation(): Promise<KeyRotationResult | null> {
// Prevent concurrent calls
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
return null;
}
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
if (!baseUrl) {
return { success: false, updated: 0, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
}
console.log('[E2EE] Triggering key rotation for regenerated keys...');
const response = await fetch(`${baseUrl}/functions/v1/handle-key-rotation`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
const text = await response.text();
console.error('[E2EE] Key rotation failed:', text);
return { success: false, updated: 0, error: text || 'Failed to rotate keys' };
}
const result = (await response.json()) as KeyRotationResult;
console.log(`[E2EE] Key rotation complete: ${result.updated} messages updated`);
return result;
} catch (err) {
console.error('[E2EE] Key rotation error:', err);
return {
success: false,
updated: 0,
error: err instanceof Error ? err.message : 'Unknown error',
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
+133
View File
@@ -0,0 +1,133 @@
/**
* E2EE Types for Sauci Chat
*
* This file contains all TypeScript interfaces for the E2EE implementation.
*/
/**
* Metadata about how message keys are wrapped for each party
*/
export interface KeysMetadata {
/** AES key encrypted with sender's RSA public key (base64) */
sender_wrapped_key: string;
/** AES key encrypted with recipient's RSA public key (base64). Null if recipient hasn't generated keys yet */
recipient_wrapped_key?: string;
/** AES key encrypted with admin's RSA public key (base64) */
admin_wrapped_key: string;
/** UUID of the master_keys record used */
admin_key_id: string;
/** Symmetric encryption algorithm */
algorithm: 'AES-256-GCM';
/** Key wrapping algorithm */
key_wrap_algorithm: 'RSA-OAEP-SHA256';
/** True if recipient key needs to be added when they generate their keys */
pending_recipient?: boolean;
}
/**
* Payload for an encrypted text message
*/
export interface EncryptedMessagePayload {
version: 2;
encrypted_content: string;
encryption_iv: string;
keys_metadata: KeysMetadata;
}
/**
* Payload for encrypted media
*/
export interface EncryptedMediaPayload {
encryptedFileUri: string;
encryption_iv: string;
keys_metadata: KeysMetadata;
}
/**
* Decrypted content result
*/
export interface DecryptedContent {
content: string | null;
mediaUrl: string | null;
}
/**
* State for useDecryptedMessage hook
*/
export type DecryptedMessageErrorCode =
| 'E2EE_NOT_SUPPORTED'
| 'E2EE_KEYS_UNAVAILABLE'
| 'E2EE_MISSING_FIELDS'
| 'E2EE_PENDING_RECIPIENT_KEY'
| 'E2EE_REPAIRING_STALE_KEY'
| 'E2EE_DECRYPT_FAILED';
export interface DecryptedMessageState {
content: string | null;
mediaUri: string | null;
isDecrypting: boolean;
error: Error | null;
errorCode?: DecryptedMessageErrorCode;
}
/**
* RSA public key in JWK format (JSON Web Key)
*/
export interface RSAPublicKeyJWK {
kty: 'RSA';
n: string; // modulus
e: string; // exponent
alg?: string;
key_ops?: string[];
}
/**
* RSA private key in JWK format (includes public key components)
*/
export interface RSAPrivateKeyJWK extends RSAPublicKeyJWK {
d: string; // private exponent
p: string; // first prime factor
q: string; // second prime factor
dp: string; // first factor CRT exponent
dq: string; // second factor CRT exponent
qi: string; // first CRT coefficient
}
/**
* State for useEncryptionKeys hook
*/
export interface EncryptionKeysState {
privateKeyJwk: RSAPrivateKeyJWK | null;
publicKeyJwk: RSAPublicKeyJWK | null;
isLoading: boolean;
hasKeys: boolean;
error: Error | null;
}
/**
* Master key record from database
*/
export interface MasterKey {
id: string;
key_name: string;
public_key_jwk: RSAPublicKeyJWK;
is_active: boolean;
created_at: string;
rotated_at: string | null;
}
/**
* Encryption context for sending messages
*/
export interface EncryptionContext {
senderPublicKeyJwk: RSAPublicKeyJWK;
recipientPublicKeyJwk: RSAPublicKeyJWK | null;
adminPublicKeyJwk: RSAPublicKeyJWK;
adminKeyId: string;
}
+72
View File
@@ -0,0 +1,72 @@
/**
* E2EE Utility Functions
*
* Base64 and ArrayBuffer conversion utilities for encryption operations.
*/
/**
* Convert ArrayBuffer to base64 string
*/
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Convert base64 string to ArrayBuffer
*/
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
/**
* Convert Uint8Array to base64 string
*/
export function uint8ArrayToBase64(array: Uint8Array): string {
let binary = '';
for (let i = 0; i < array.length; i++) {
binary += String.fromCharCode(array[i]);
}
return btoa(binary);
}
/**
* Convert base64 string to Uint8Array
*/
export function base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Split string into chunks of specified size
*/
export function chunkString(str: string, size: number): string[] {
const chunks: string[] = [];
for (let i = 0; i < str.length; i += size) {
chunks.push(str.slice(i, i + size));
}
return chunks;
}
/**
* Generate a random string for unique identifiers
*/
export function generateRandomId(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Crypto polyfill for React Native
*
* This file must be imported BEFORE any code that uses crypto.
* It installs react-native-quick-crypto as the global crypto implementation.
*
* On web, we skip this since the native Web Crypto API is available.
*/
import { Platform } from 'react-native';
if (Platform.OS !== 'web') {
try {
// Import react-native-quick-crypto to install its polyfill
const QuickCrypto = require('react-native-quick-crypto');
// Install the polyfill globally if not already present
if (typeof global.crypto === 'undefined') {
(global as any).crypto = QuickCrypto;
}
// Also ensure subtle is available
if (global.crypto && !global.crypto.subtle) {
(global.crypto as any).subtle = QuickCrypto.subtle;
}
console.log('[Crypto] react-native-quick-crypto polyfill installed');
} catch (error) {
console.error('[Crypto] Failed to install crypto polyfill:', error);
console.error('[Crypto] Make sure you have run: npx expo prebuild --clean');
}
}
+96
View File
@@ -1,7 +1,23 @@
import { create } from "zustand"; import { create } from "zustand";
import { supabase } from "../lib/supabase"; import { supabase } from "../lib/supabase";
import { Events } from "../lib/analytics"; import { Events } from "../lib/analytics";
import { clearKeys } from "../lib/encryption";
import type { Profile, Couple } from "@/types"; import type { Profile, Couple } from "@/types";
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from "../lib/encryption/types";
/**
* Encryption key state - stored globally to ensure all components
* share the same key state and avoid race conditions during encryption.
*/
interface EncryptionKeyState {
privateKeyJwk: RSAPrivateKeyJWK | null;
publicKeyJwk: RSAPublicKeyJWK | null;
hasKeys: boolean;
isLoadingKeys: boolean;
keysError: Error | null;
/** Incremented when keys change - consumers can use this to trigger re-renders */
keysVersion: number;
}
interface AuthState { interface AuthState {
user: Profile | null; user: Profile | null;
@@ -10,11 +26,19 @@ interface AuthState {
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
// Encryption key state (global to avoid hook instance sync issues)
encryptionKeys: EncryptionKeyState;
// Actions // Actions
fetchUser: () => Promise<void>; fetchUser: () => Promise<void>;
fetchCouple: () => Promise<void>; fetchCouple: () => Promise<void>;
refreshPartner: () => Promise<Profile | null>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
setUser: (user: Profile | null) => void; setUser: (user: Profile | null) => void;
// Encryption key actions
setEncryptionKeys: (keys: Partial<EncryptionKeyState>) => void;
clearEncryptionKeys: () => void;
} }
// Import other stores lazily to avoid circular dependency issues // Import other stores lazily to avoid circular dependency issues
@@ -26,12 +50,22 @@ const getOtherStores = () => {
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore }; return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore };
}; };
const initialEncryptionKeyState: EncryptionKeyState = {
privateKeyJwk: null,
publicKeyJwk: null,
hasKeys: false,
isLoadingKeys: true,
keysError: null,
keysVersion: 0,
};
export const useAuthStore = create<AuthState>((set, get) => ({ export const useAuthStore = create<AuthState>((set, get) => ({
user: null, user: null,
couple: null, couple: null,
partner: null, partner: null,
isLoading: true, isLoading: true,
isAuthenticated: false, isAuthenticated: false,
encryptionKeys: initialEncryptionKeyState,
fetchUser: async () => { fetchUser: async () => {
try { try {
@@ -49,6 +83,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (authError || !authUser) { if (authError || !authUser) {
console.log("[Auth] Session invalid, signing out:", authError?.message); console.log("[Auth] Session invalid, signing out:", authError?.message);
// Clear E2EE keys
try {
await clearKeys();
} catch (error) {
console.error("Failed to clear encryption keys:", error);
}
// Session is invalid - clear everything // Session is invalid - clear everything
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false }); set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
// Clear other stores // Clear other stores
@@ -108,8 +150,37 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ couple, partner }); set({ couple, partner });
}, },
/**
* Refresh partner's profile data (useful to get latest public_key_jwk for E2EE)
* Returns the updated partner profile or null if not found
*/
refreshPartner: async () => {
const user = get().user;
if (!user?.couple_id) return null;
const { data: partner } = await supabase
.from("profiles")
.select("*")
.eq("couple_id", user.couple_id)
.neq("id", user.id)
.maybeSingle();
if (partner) {
set({ partner });
}
return partner;
},
signOut: async () => { signOut: async () => {
Events.signOut(); Events.signOut();
// Clear E2EE keys from storage
try {
await clearKeys();
} catch (error) {
console.error("Failed to clear encryption keys:", error);
}
// Clear local state FIRST to ensure UI updates even if Supabase call fails // Clear local state FIRST to ensure UI updates even if Supabase call fails
set({ set({
user: null, user: null,
@@ -117,6 +188,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
partner: null, partner: null,
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
encryptionKeys: initialEncryptionKeyState,
}); });
// Clear other stores // Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores(); const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores();
@@ -150,4 +222,28 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useSubscriptionStore.getState().clearSubscription(); useSubscriptionStore.getState().clearSubscription();
} }
}, },
/**
* Update encryption key state. This is the ONLY way to update keys
* to ensure all consumers see the same state.
*/
setEncryptionKeys: (keys) => {
set((state) => ({
encryptionKeys: {
...state.encryptionKeys,
...keys,
// Increment version when keys actually change
keysVersion: (keys.publicKeyJwk !== undefined || keys.privateKeyJwk !== undefined)
? state.encryptionKeys.keysVersion + 1
: state.encryptionKeys.keysVersion,
},
}));
},
/**
* Clear encryption keys (called on sign out or when leaving couple)
*/
clearEncryptionKeys: () => {
set({ encryptionKeys: initialEncryptionKeyState });
},
})); }));
+1
View File
@@ -38,6 +38,7 @@ export const colors = {
warningLight: 'rgba(243, 156, 18, 0.15)', warningLight: 'rgba(243, 156, 18, 0.15)',
error: '#E74C3C', error: '#E74C3C',
errorLight: 'rgba(231, 76, 60, 0.15)', errorLight: 'rgba(231, 76, 60, 0.15)',
info: '#53bdeb', // WhatsApp-like blue for read receipts
// Premium/Luxury // Premium/Luxury
premium: { premium: {
+2
View File
@@ -14,6 +14,8 @@ export interface Profile {
onboarding_completed: boolean; onboarding_completed: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
/** RSA public key for E2EE (JWK format) */
public_key_jwk?: Record<string, unknown> | null;
} }
// Couple pairing // Couple pairing
+146 -5
View File
@@ -44,6 +44,57 @@ export type Database = {
} }
Relationships: [] Relationships: []
} }
ai_config: {
Row: {
cherry_pick_ensure_intensity_distribution: boolean | null
council_enabled: boolean | null
council_generator_model: string | null
council_generators: Json | null
council_reviewer_model: string | null
council_selection_mode: string | null
default_model: string | null
id: string
model_fix: string | null
model_generate: string | null
model_polish: string | null
openrouter_api_key: string | null
updated_at: string | null
updated_by: string | null
}
Insert: {
cherry_pick_ensure_intensity_distribution?: boolean | null
council_enabled?: boolean | null
council_generator_model?: string | null
council_generators?: Json | null
council_reviewer_model?: string | null
council_selection_mode?: string | null
default_model?: string | null
id?: string
model_fix?: string | null
model_generate?: string | null
model_polish?: string | null
openrouter_api_key?: string | null
updated_at?: string | null
updated_by?: string | null
}
Update: {
cherry_pick_ensure_intensity_distribution?: boolean | null
council_enabled?: boolean | null
council_generator_model?: string | null
council_generators?: Json | null
council_reviewer_model?: string | null
council_selection_mode?: string | null
default_model?: string | null
id?: string
model_fix?: string | null
model_generate?: string | null
model_polish?: string | null
openrouter_api_key?: string | null
updated_at?: string | null
updated_by?: string | null
}
Relationships: []
}
app_config: { app_config: {
Row: { Row: {
answer_gap_threshold: number | null answer_gap_threshold: number | null
@@ -295,6 +346,33 @@ export type Database = {
}, },
] ]
} }
master_keys: {
Row: {
created_at: string | null
id: string
is_active: boolean | null
key_name: string
public_key_jwk: Json
rotated_at: string | null
}
Insert: {
created_at?: string | null
id?: string
is_active?: boolean | null
key_name: string
public_key_jwk: Json
rotated_at?: string | null
}
Update: {
created_at?: string | null
id?: string
is_active?: boolean | null
key_name?: string
public_key_jwk?: Json
rotated_at?: string | null
}
Relationships: []
}
matches: { matches: {
Row: { Row: {
couple_id: string couple_id: string
@@ -337,12 +415,52 @@ export type Database = {
}, },
] ]
} }
message_deletions: {
Row: {
id: string
message_id: string
user_id: string
deleted_at: string | null
}
Insert: {
id?: string
message_id: string
user_id: string
deleted_at?: string | null
}
Update: {
id?: string
message_id?: string
user_id?: string
deleted_at?: string | null
}
Relationships: [
{
foreignKeyName: "message_deletions_message_id_fkey"
columns: ["message_id"]
isOneToOne: false
referencedRelation: "messages"
referencedColumns: ["id"]
},
{
foreignKeyName: "message_deletions_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "users"
referencedColumns: ["id"]
},
]
}
messages: { messages: {
Row: { Row: {
content: string | null content: string | null
created_at: string | null created_at: string | null
deleted_at: string | null
delivered_at: string | null delivered_at: string | null
encrypted_content: string | null
encryption_iv: string | null
id: string id: string
keys_metadata: Json | null
match_id: string match_id: string
media_expired: boolean | null media_expired: boolean | null
media_expires_at: string | null media_expires_at: string | null
@@ -351,12 +469,17 @@ export type Database = {
media_viewed_at: string | null media_viewed_at: string | null
read_at: string | null read_at: string | null
user_id: string user_id: string
version: number | null
} }
Insert: { Insert: {
content?: string | null content?: string | null
created_at?: string | null created_at?: string | null
deleted_at?: string | null
delivered_at?: string | null delivered_at?: string | null
encrypted_content?: string | null
encryption_iv?: string | null
id?: string id?: string
keys_metadata?: Json | null
match_id: string match_id: string
media_expired?: boolean | null media_expired?: boolean | null
media_expires_at?: string | null media_expires_at?: string | null
@@ -365,12 +488,17 @@ export type Database = {
media_viewed_at?: string | null media_viewed_at?: string | null
read_at?: string | null read_at?: string | null
user_id: string user_id: string
version?: number | null
} }
Update: { Update: {
content?: string | null content?: string | null
created_at?: string | null created_at?: string | null
deleted_at?: string | null
delivered_at?: string | null delivered_at?: string | null
encrypted_content?: string | null
encryption_iv?: string | null
id?: string id?: string
keys_metadata?: Json | null
match_id?: string match_id?: string
media_expired?: boolean | null media_expired?: boolean | null
media_expires_at?: string | null media_expires_at?: string | null
@@ -379,6 +507,7 @@ export type Database = {
media_viewed_at?: string | null media_viewed_at?: string | null
read_at?: string | null read_at?: string | null
user_id?: string user_id?: string
version?: number | null
} }
Relationships: [ Relationships: [
{ {
@@ -431,6 +560,7 @@ export type Database = {
is_premium: boolean | null is_premium: boolean | null
name: string | null name: string | null
onboarding_completed: boolean | null onboarding_completed: boolean | null
public_key_jwk: Json | null
push_token: string | null push_token: string | null
show_explicit_content: boolean | null show_explicit_content: boolean | null
updated_at: string | null updated_at: string | null
@@ -446,6 +576,7 @@ export type Database = {
is_premium?: boolean | null is_premium?: boolean | null
name?: string | null name?: string | null
onboarding_completed?: boolean | null onboarding_completed?: boolean | null
public_key_jwk?: Json | null
push_token?: string | null push_token?: string | null
show_explicit_content?: boolean | null show_explicit_content?: boolean | null
updated_at?: string | null updated_at?: string | null
@@ -461,6 +592,7 @@ export type Database = {
is_premium?: boolean | null is_premium?: boolean | null
name?: string | null name?: string | null
onboarding_completed?: boolean | null onboarding_completed?: boolean | null
public_key_jwk?: Json | null
push_token?: string | null push_token?: string | null
show_explicit_content?: boolean | null show_explicit_content?: boolean | null
updated_at?: string | null updated_at?: string | null
@@ -774,7 +906,7 @@ export type Database = {
} }
Functions: { Functions: {
cleanup_expired_videos: { cleanup_expired_videos: {
Args: Record<PropertyKey, never> Args: never
Returns: { Returns: {
deleted_count: number deleted_count: number
deleted_paths: string[] deleted_paths: string[]
@@ -785,14 +917,22 @@ export type Database = {
Returns: Database["public"]["Enums"]["admin_role"] Returns: Database["public"]["Enums"]["admin_role"]
} }
get_answer_gap_status: { get_answer_gap_status: {
Args: Record<PropertyKey, never> Args: never
Returns: { Returns: {
is_blocked: boolean is_blocked: boolean
threshold: number threshold: number
unanswered_by_partner: number unanswered_by_partner: number
}[] }[]
} }
get_auth_user_couple_id: { Args: Record<PropertyKey, never>; Returns: string } get_auth_user_couple_id: { Args: never; Returns: string }
get_feature_interest_counts: {
Args: never
Returns: {
feature_name: string
opt_in_count: number
opt_in_count_last_7_days: number
}[]
}
get_pack_teaser_questions: { get_pack_teaser_questions: {
Args: { target_pack_id: string } Args: { target_pack_id: string }
Returns: { Returns: {
@@ -802,7 +942,7 @@ export type Database = {
}[] }[]
} }
get_profiles_with_auth_info: { get_profiles_with_auth_info: {
Args: Record<PropertyKey, never> Args: never
Returns: { Returns: {
avatar_url: string avatar_url: string
couple_id: string couple_id: string
@@ -840,12 +980,13 @@ export type Database = {
}[] }[]
} }
get_user_storage_usage: { get_user_storage_usage: {
Args: Record<PropertyKey, never> Args: never
Returns: { Returns: {
owner: string owner: string
total_bytes: number total_bytes: number
}[] }[]
} }
has_permission: { Args: { check_permission: string }; Returns: boolean }
has_premium_access: { Args: { check_user_id: string }; Returns: boolean } has_premium_access: { Args: { check_user_id: string }; Returns: boolean }
is_admin: { Args: { check_user_id?: string }; Returns: boolean } is_admin: { Args: { check_user_id?: string }; Returns: boolean }
is_super_admin: { Args: { check_user_id?: string }; Returns: boolean } is_super_admin: { Args: { check_user_id?: string }; Returns: boolean }
@@ -0,0 +1,38 @@
import { decode, encode } from "https://deno.land/std@0.208.0/encoding/base64.ts";
export interface KeysMetadata {
sender_wrapped_key: string;
recipient_wrapped_key?: string;
admin_wrapped_key: string;
admin_key_id: string;
algorithm: string;
key_wrap_algorithm: string;
pending_recipient?: boolean;
}
export async function unwrapWithAdminKey(
wrappedKeyBase64: string,
adminPrivateKeyJwk: JsonWebKey
): Promise<ArrayBuffer> {
const privateKey = await crypto.subtle.importKey(
"jwk",
adminPrivateKeyJwk,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["decrypt"]
);
return await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
privateKey,
base64ToUint8Array(wrappedKeyBase64)
);
}
export function base64ToUint8Array(base64: string): Uint8Array {
return decode(base64);
}
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
return encode(buffer);
}
@@ -0,0 +1,190 @@
// @ts-nocheck
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { decode } from "https://deno.land/std@0.208.0/encoding/base64.ts";
import { unwrapWithAdminKey, base64ToUint8Array, KeysMetadata } from "../_shared/encryption.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (req.method !== "POST") {
return json({ error: "Method not allowed" }, 405);
}
try {
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const user = await getUserFromRequest(supabaseAdmin, req);
await assertSuperAdmin(supabaseAdmin, user.id);
const body = await req.json().catch(() => ({}));
const messageId = typeof body?.messageId === "string" ? body.messageId : null;
if (!messageId) {
return json({ error: "Missing messageId" }, 400);
}
const { data: message, error: messageError } = await supabaseAdmin
.from("messages")
.select(
"id, version, media_path, media_type, encryption_iv, keys_metadata"
)
.eq("id", messageId)
.single();
if (messageError || !message) {
return json({ error: "Message not found" }, 404);
}
const mediaPath = message.media_path as string | null;
const mediaType = message.media_type as ("image" | "video" | null);
const version = message.version ?? 1;
if (!mediaPath) {
return json({ error: "Message has no media" }, 400);
}
const normalizedPath = normalizeChatMediaPath(mediaPath);
const { data: mediaBlob, error: downloadError } = await supabaseAdmin.storage
.from("chat-media")
.download(normalizedPath);
if (downloadError || !mediaBlob) {
return json({ error: `Failed to download media: ${downloadError.message}` }, 500);
}
const encryptedBytes = await mediaBlob.arrayBuffer();
let outputBytes = encryptedBytes;
if (version === 2) {
const encryptionIv = message.encryption_iv as string | null;
const keysMetadata = message.keys_metadata as KeysMetadata | null;
if (!encryptionIv || !keysMetadata) {
return json({ error: "Missing encryption metadata" }, 400);
}
const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
if (!adminPrivateKeyJwkStr) {
return json({ error: "Admin decryption key not configured" }, 500);
}
const rawAesKey = await unwrapWithAdminKey(
keysMetadata.admin_wrapped_key,
JSON.parse(adminPrivateKeyJwkStr)
);
const aesKey = await crypto.subtle.importKey(
"raw",
rawAesKey,
{ name: "AES-GCM" },
false,
["decrypt"]
);
outputBytes = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
aesKey,
encryptedBytes
);
}
const contentType =
mediaType === "video" ? "video/mp4" : mediaType === "image" ? "image/jpeg" : "application/octet-stream";
return new Response(outputBytes, {
status: 200,
headers: {
...corsHeaders,
"Content-Type": contentType,
"Cache-Control": "no-store",
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message.includes("authorization header") || message.startsWith("Invalid token")) {
return json({ error: "Unauthorized" }, 401);
}
if (message === "Access denied") {
return json({ error: "Forbidden" }, 403);
}
console.error("admin-decrypt-media error:", error);
return json({ error: "Internal server error" }, 500);
}
});
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: {
...corsHeaders,
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
});
}
async function getUserFromRequest(
supabaseAdmin: ReturnType<typeof createClient>,
req: Request
) {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
throw new Error("Missing authorization header");
}
const { data, error } = await supabaseAdmin.auth.getUser(
authHeader.replace("Bearer ", "")
);
if (error || !data.user) {
throw new Error(`Invalid token: ${error?.message || "No user"}`);
}
return data.user;
}
async function assertSuperAdmin(
supabaseAdmin: ReturnType<typeof createClient>,
userId: string
) {
const { data, error } = await supabaseAdmin
.from("admin_users")
.select("role")
.eq("user_id", userId)
.maybeSingle();
if (error || !data) {
throw new Error("Access denied");
}
if (data.role !== "super_admin") {
throw new Error("Access denied");
}
}
function normalizeChatMediaPath(mediaPath: string): string {
if (mediaPath.startsWith("http")) {
const parts = mediaPath.split("/chat-media/");
if (parts.length > 1) {
return decodeURIComponent(parts[1]);
}
}
return mediaPath;
}
@@ -0,0 +1,168 @@
// @ts-nocheck
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { unwrapWithAdminKey, base64ToUint8Array, KeysMetadata } from "../_shared/encryption.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (req.method !== "POST") {
return json({ error: "Method not allowed" }, 405);
}
try {
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const user = await getUserFromRequest(supabaseAdmin, req);
await assertSuperAdmin(supabaseAdmin, user.id);
const body = await req.json().catch(() => ({}));
const messageId = typeof body?.messageId === "string" ? body.messageId : null;
if (!messageId) {
return json({ error: "Missing messageId" }, 400);
}
const { data: message, error: messageError } = await supabaseAdmin
.from("messages")
.select(
"id, version, content, encrypted_content, encryption_iv, keys_metadata, media_path, media_type"
)
.eq("id", messageId)
.single();
if (messageError || !message) {
return json({ error: "Message not found" }, 404);
}
const version = message.version ?? 1;
if (version !== 2) {
return json({
version,
content: message.content ?? null,
media_path: message.media_path ?? null,
media_type: message.media_type ?? null,
});
}
const encryptedContent = message.encrypted_content as string | null;
const encryptionIv = message.encryption_iv as string | null;
const keysMetadata = message.keys_metadata as KeysMetadata | null;
let decryptedContent: string | null = null;
if (encryptedContent) {
if (!encryptionIv || !keysMetadata) {
return json({ error: "Missing encryption metadata" }, 400);
}
const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
if (!adminPrivateKeyJwkStr) {
return json({ error: "Admin decryption key not configured" }, 500);
}
const rawAesKey = await unwrapWithAdminKey(
keysMetadata.admin_wrapped_key,
JSON.parse(adminPrivateKeyJwkStr)
);
const aesKey = await crypto.subtle.importKey(
"raw",
rawAesKey,
{ name: "AES-GCM" },
false,
["decrypt"]
);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
aesKey,
base64ToUint8Array(encryptedContent)
);
decryptedContent = new TextDecoder().decode(plaintext);
}
return json({
version,
content: decryptedContent,
media_path: message.media_path ?? null,
media_type: message.media_type ?? null,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message.includes("authorization header") || message.startsWith("Invalid token")) {
return json({ error: "Unauthorized" }, 401);
}
if (message === "Access denied") {
return json({ error: "Forbidden" }, 403);
}
console.error("admin-decrypt-message error:", error);
return json({ error: "Internal server error" }, 500);
}
});
function json(body: unknown, status = 200, extraHeaders: Record<string, string> = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
...corsHeaders,
"Content-Type": "application/json",
"Cache-Control": "no-store",
...extraHeaders,
},
});
}
async function getUserFromRequest(
supabaseAdmin: ReturnType<typeof createClient>,
req: Request
) {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
throw new Error("Missing authorization header");
}
const { data, error } = await supabaseAdmin.auth.getUser(
authHeader.replace("Bearer ", "")
);
if (error || !data.user) {
throw new Error(`Invalid token: ${error?.message || "No user"}`);
}
return data.user;
}
async function assertSuperAdmin(
supabaseAdmin: ReturnType<typeof createClient>,
userId: string
) {
const { data, error } = await supabaseAdmin
.from("admin_users")
.select("role")
.eq("user_id", userId)
.maybeSingle();
if (error || !data) {
throw new Error("Access denied");
}
if (data.role !== "super_admin") {
throw new Error("Access denied");
}
}
@@ -0,0 +1,228 @@
// @ts-nocheck
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { unwrapWithAdminKey, base64ToUint8Array, arrayBufferToBase64, KeysMetadata } from "../_shared/encryption.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const body = await req.json().catch(() => ({}));
const messageId = body.messageId || body.message_id; // Support both cases
if (!messageId) {
return new Response(JSON.stringify({ error: "Missing message_id" }), { status: 400, headers: corsHeaders });
}
// 1. Fetch AI Config
const { data: config, error: configError } = await supabaseAdmin
.from("ai_config")
.select("*")
.single();
if (configError || !config) {
console.error("Failed to fetch AI config:", configError);
return new Response(JSON.stringify({ error: "Configuration missing" }), { status: 500, headers: corsHeaders });
}
if (!config.classifier_enabled) {
return new Response(JSON.stringify({ message: "Classifier disabled" }), { status: 200, headers: corsHeaders });
}
const apiKey = config.openrouter_api_key || Deno.env.get("OPENROUTER_API_KEY");
if (!apiKey) {
console.error("OpenRouter API Key missing");
return new Response(JSON.stringify({ error: "API Key missing" }), { status: 500, headers: corsHeaders });
}
// 2. Fetch Message
const { data: message, error: messageError } = await supabaseAdmin
.from("messages")
.select("id, version, content, encrypted_content, encryption_iv, keys_metadata, media_path, media_type")
.eq("id", messageId)
.single();
if (messageError || !message) {
return new Response(JSON.stringify({ error: "Message not found" }), { status: 404, headers: corsHeaders });
}
// 3. Decrypt Content
const version = message.version ?? 1;
let decryptedText = message.content;
let mediaBase64: string | null = null;
let mediaMimeType: string | null = null;
if (version === 2) {
const keysMetadata = message.keys_metadata as KeysMetadata;
const encryptionIv = message.encryption_iv;
if (!keysMetadata || !encryptionIv) {
return new Response(JSON.stringify({ error: "Missing encryption metadata" }), { status: 400, headers: corsHeaders });
}
const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
if (!adminPrivateKeyJwkStr) {
throw new Error("ADMIN_PRIVATE_KEY_JWK not set");
}
const adminPrivateKey = JSON.parse(adminPrivateKeyJwkStr);
const rawAesKey = await unwrapWithAdminKey(keysMetadata.admin_wrapped_key, adminPrivateKey);
const aesKey = await crypto.subtle.importKey(
"raw",
rawAesKey,
{ name: "AES-GCM" },
false,
["decrypt"]
);
// Decrypt Text
if (message.encrypted_content) {
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
aesKey,
base64ToUint8Array(message.encrypted_content)
);
decryptedText = new TextDecoder().decode(plaintext);
}
// Decrypt Media (Image only for now)
if (message.media_path && message.media_type === "image") {
const { data: mediaBlob, error: downloadError } = await supabaseAdmin.storage
.from("chat-media")
.download(normalizeChatMediaPath(message.media_path));
if (!downloadError && mediaBlob) {
const encryptedBytes = await mediaBlob.arrayBuffer();
const decryptedBytes = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
aesKey,
encryptedBytes
);
mediaBase64 = arrayBufferToBase64(decryptedBytes);
mediaMimeType = "image/jpeg"; // Default or infer?
}
}
} else {
// Version 1 (Plaintext)
if (message.media_path && message.media_type === "image") {
const { data: mediaBlob } = await supabaseAdmin.storage
.from("chat-media")
.download(normalizeChatMediaPath(message.media_path));
if (mediaBlob) {
const buffer = await mediaBlob.arrayBuffer();
mediaBase64 = arrayBufferToBase64(buffer);
mediaMimeType = "image/jpeg";
}
}
}
// 4. Construct LLM Payload
const messages = [
{ role: "system", content: config.classifier_prompt },
{
role: "user",
content: [
decryptedText ? { type: "text", text: decryptedText } : null,
mediaBase64 ? {
type: "image_url",
image_url: { url: `data:${mediaMimeType};base64,${mediaBase64}` }
} : null
].filter(Boolean)
}
];
// If no content to classify, mark safe
if (messages[1].content.length === 0) {
await updateStatus(supabaseAdmin, messageId, "safe", "No content");
return new Response(JSON.stringify({ status: "safe" }), { headers: corsHeaders });
}
// 5. Call OpenRouter
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://sauci.app",
"X-Title": "Sauci"
},
body: JSON.stringify({
model: config.classifier_model || "openai/gpt-4o",
messages: messages,
response_format: { type: "json_object" }
})
});
if (!response.ok) {
const errText = await response.text();
console.error("OpenRouter Error:", errText);
throw new Error(`OpenRouter API Error: ${response.status}`);
}
const aiResult = await response.json();
const contentStr = aiResult.choices?.[0]?.message?.content;
if (!contentStr) {
throw new Error("Empty response from AI");
}
let classification;
try {
classification = JSON.parse(contentStr);
} catch (e) {
console.error("Failed to parse JSON:", contentStr);
// Fallback: simple text check
const lower = contentStr.toLowerCase();
if (lower.includes("flagged") || lower.includes("unsafe")) {
classification = { status: "flagged", reason: "AI output parsing failed, but keyword detected." };
} else {
classification = { status: "safe" };
}
}
// 6. Update Message
const status = classification.status === "flagged" ? "flagged" : "safe";
const reason = classification.reason || null;
const category = classification.category || "Neutral"; // Default to Neutral if missing
await updateStatus(supabaseAdmin, messageId, status, reason, category);
return new Response(JSON.stringify({ success: true, classification }), { headers: corsHeaders });
} catch (error) {
console.error("Classify Error:", error);
return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: corsHeaders });
}
});
async function updateStatus(supabase, id, status, reason, category) {
await supabase.from("messages").update({
moderation_status: status,
flag_reason: reason,
category: category
}).eq("id", id);
}
function normalizeChatMediaPath(mediaPath: string): string {
if (mediaPath.startsWith("http")) {
const parts = mediaPath.split("/chat-media/");
if (parts.length > 1) {
return decodeURIComponent(parts[1]);
}
}
return mediaPath;
}
@@ -0,0 +1,315 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
// Only re-wrap messages from the last 7 days synchronously
// Older messages will be repaired on-demand via repair-stale-key
const RECENT_DAYS = 7;
const MAX_MESSAGES_PER_REQUEST = 500;
/**
* Handles key rotation when a user gets a new device.
*
* When a user's public key changes (detected via database trigger or called manually),
* this function re-wraps RECENT message AES keys with the user's new public key.
* Older messages are repaired on-demand when the user tries to view them.
* This prevents timeouts for couples with large message histories.
*/
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// Get user from JWT
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Missing authorization header" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const { data: { user }, error: authError } = await supabaseAdmin.auth.getUser(
authHeader.replace("Bearer ", "")
);
if (authError || !user) {
return new Response(
JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get user's couple_id and new public key
const { data: profile, error: profileError } = await supabaseAdmin
.from("profiles")
.select("couple_id, public_key_jwk")
.eq("id", user.id)
.maybeSingle();
if (profileError || !profile) {
return new Response(
JSON.stringify({ error: "Profile not found" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!profile.couple_id) {
return new Response(
JSON.stringify({ success: true, updated: 0, reason: "No couple" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!profile.public_key_jwk) {
return new Response(
JSON.stringify({ error: "User has no public key" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Find all matches for this couple
const { data: matches } = await supabaseAdmin
.from("matches")
.select("id")
.eq("couple_id", profile.couple_id);
const matchIds = matches?.map(m => m.id) || [];
if (matchIds.length === 0) {
return new Response(
JSON.stringify({ success: true, updated: 0, reason: "No matches found" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Calculate cutoff date for recent messages
const recentCutoff = new Date(Date.now() - RECENT_DAYS * 24 * 60 * 60 * 1000);
// Find RECENT v2 messages in these matches (lazy re-encryption)
const { data: messages, error: messagesError } = await supabaseAdmin
.from("messages")
.select("id, user_id, keys_metadata, created_at")
.eq("version", 2)
.in("match_id", matchIds)
.gte("created_at", recentCutoff.toISOString())
.order("created_at", { ascending: false })
.limit(MAX_MESSAGES_PER_REQUEST);
if (messagesError) {
console.error("Error fetching messages:", messagesError);
return new Response(
JSON.stringify({ error: "Failed to fetch messages" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Check if there are older messages that weren't processed
const { count: totalCount } = await supabaseAdmin
.from("messages")
.select("id", { count: "exact", head: true })
.eq("version", 2)
.in("match_id", matchIds);
const hasOlderMessages = (totalCount ?? 0) > (messages?.length ?? 0);
let updatedCount = 0;
let errorCount = 0;
for (const message of messages || []) {
const metadata = message.keys_metadata as {
sender_wrapped_key: string;
recipient_wrapped_key?: string;
admin_wrapped_key: string;
admin_key_id: string;
algorithm: string;
key_wrap_algorithm: string;
pending_recipient?: boolean;
};
const isSender = message.user_id === user.id;
try {
// Get admin private key - supports multi-key lookup
const adminPrivateKeyJwk = getAdminPrivateKey(metadata.admin_key_id);
if (!adminPrivateKeyJwk) {
console.error(`Admin key ${metadata.admin_key_id} not found for message ${message.id}`);
errorCount++;
continue;
}
// Unwrap AES key using admin key
const rawAesKey = await unwrapWithAdminKey(
metadata.admin_wrapped_key,
adminPrivateKeyJwk
);
// Re-wrap with new public key
const newWrappedKey = await wrapForRecipient(
rawAesKey,
profile.public_key_jwk
);
// Update the appropriate field based on whether user is sender or recipient
const updatedMetadata = { ...metadata };
if (isSender) {
updatedMetadata.sender_wrapped_key = newWrappedKey;
} else {
updatedMetadata.recipient_wrapped_key = newWrappedKey;
updatedMetadata.pending_recipient = false;
}
const { error: updateError } = await supabaseAdmin
.from("messages")
.update({ keys_metadata: updatedMetadata })
.eq("id", message.id);
if (updateError) {
console.error(`Failed to update message ${message.id}:`, updateError);
errorCount++;
} else {
updatedCount++;
}
} catch (err) {
console.error(`Failed to re-encrypt message ${message.id}:`, err);
errorCount++;
}
}
// Log the key rotation event for audit purposes
console.log(`Key rotation completed for user ${user.id}: ${updatedCount} messages updated, ${errorCount} errors, hasOlderMessages=${hasOlderMessages}`);
return new Response(
JSON.stringify({
success: true,
updated: updatedCount,
errors: errorCount,
has_older_messages: hasOlderMessages,
recent_days: RECENT_DAYS,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
/**
* Get admin private key by ID.
* Supports multi-key format: ADMIN_KEYS_JSON = { "uuid1": {...}, "uuid2": {...} }
* Falls back to single-key format: ADMIN_PRIVATE_KEY_JWK = {...}
*/
function getAdminPrivateKey(keyId: string): JsonWebKey | null {
// Try multi-key format first
const keysJson = Deno.env.get("ADMIN_KEYS_JSON");
if (keysJson) {
try {
const keys = JSON.parse(keysJson);
if (keys[keyId]) {
return keys[keyId];
}
} catch {
console.error("Failed to parse ADMIN_KEYS_JSON");
}
}
// Fall back to single-key format
const singleKeyJson = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
if (singleKeyJson) {
try {
return JSON.parse(singleKeyJson);
} catch {
console.error("Failed to parse ADMIN_PRIVATE_KEY_JWK");
}
}
return null;
}
// Crypto helper functions
async function unwrapWithAdminKey(
wrappedKeyBase64: string,
adminPrivateKeyJwk: JsonWebKey
): Promise<ArrayBuffer> {
const privateKey = await crypto.subtle.importKey(
"jwk",
adminPrivateKeyJwk,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["decrypt"]
);
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
return await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
privateKey,
wrappedKey
);
}
async function wrapForRecipient(
rawAesKey: ArrayBuffer,
recipientPublicKeyJwk: JsonWebKey
): Promise<string> {
// Sanitize JWK to ensure Base64URL encoding for n and e
const sanitizedJwk = { ...recipientPublicKeyJwk };
if (sanitizedJwk.n) {
sanitizedJwk.n = toBase64Url(sanitizedJwk.n);
}
if (sanitizedJwk.e) {
sanitizedJwk.e = toBase64Url(sanitizedJwk.e);
}
const publicKey = await crypto.subtle.importKey(
"jwk",
sanitizedJwk,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["encrypt"]
);
const wrappedKey = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
publicKey,
rawAesKey
);
return arrayBufferToBase64(wrappedKey);
}
function toBase64Url(base64: string): string {
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
@@ -0,0 +1,282 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
/**
* Re-encrypts pending messages when a partner generates their encryption keys.
*
* Called when a user uploads their public key and their partner already
* sent messages with pending_recipient = true.
*/
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// Get user from JWT
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Missing authorization header" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const { data: { user }, error: authError } = await supabaseAdmin.auth.getUser(
authHeader.replace("Bearer ", "")
);
if (authError || !user) {
return new Response(
JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get user's couple_id and public key
const { data: profile, error: profileError } = await supabaseAdmin
.from("profiles")
.select("couple_id, public_key_jwk")
.eq("id", user.id)
.maybeSingle();
if (profileError || !profile) {
return new Response(
JSON.stringify({ error: "Profile not found" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!profile.couple_id) {
return new Response(
JSON.stringify({ success: true, updated: 0, reason: "No couple" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!profile.public_key_jwk) {
return new Response(
JSON.stringify({ error: "User has no public key" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Find matches for this couple
const { data: matches } = await supabaseAdmin
.from("matches")
.select("id")
.eq("couple_id", profile.couple_id);
const matchIds = matches?.map(m => m.id) || [];
if (matchIds.length === 0) {
return new Response(
JSON.stringify({ success: true, updated: 0, reason: "No matches found" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Find v2 messages where this user is the recipient but doesn't have their wrapped key yet
const { data: messages, error: messagesError } = await supabaseAdmin
.from("messages")
.select("id, user_id, keys_metadata")
.eq("version", 2)
.in("match_id", matchIds)
.neq("user_id", user.id); // Messages sent TO this user (they are recipient)
if (messagesError) {
console.error("Error fetching messages:", messagesError);
return new Response(
JSON.stringify({ error: "Failed to fetch messages" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
let updatedCount = 0;
for (const message of messages || []) {
const metadata = message.keys_metadata as {
sender_wrapped_key: string;
recipient_wrapped_key?: string;
admin_wrapped_key: string;
admin_key_id: string;
algorithm: string;
key_wrap_algorithm: string;
pending_recipient?: boolean;
};
// Only process if pending_recipient OR missing recipient key
if (!metadata.pending_recipient && metadata.recipient_wrapped_key) {
continue;
}
try {
// Get admin private key - supports multi-key lookup
const adminPrivateKeyJwk = getAdminPrivateKey(metadata.admin_key_id);
if (!adminPrivateKeyJwk) {
console.error(`Admin key ${metadata.admin_key_id} not found for message ${message.id}`);
continue;
}
// Unwrap AES key using admin key
const rawAesKey = await unwrapWithAdminKey(
metadata.admin_wrapped_key,
adminPrivateKeyJwk
);
// Re-wrap for recipient
const recipientWrappedKey = await wrapForRecipient(
rawAesKey,
profile.public_key_jwk
);
// Update message
const { error: updateError } = await supabaseAdmin
.from("messages")
.update({
keys_metadata: {
...metadata,
recipient_wrapped_key: recipientWrappedKey,
pending_recipient: false,
}
})
.eq("id", message.id);
if (updateError) {
console.error(`Failed to update message ${message.id}:`, updateError);
} else {
updatedCount++;
}
} catch (err) {
console.error(`Failed to re-encrypt message ${message.id}:`, err);
}
}
return new Response(
JSON.stringify({ success: true, updated: updatedCount }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
/**
* Get admin private key by ID.
* Supports multi-key format: ADMIN_KEYS_JSON = { "uuid1": {...}, "uuid2": {...} }
* Falls back to single-key format: ADMIN_PRIVATE_KEY_JWK = {...}
*/
function getAdminPrivateKey(keyId: string): JsonWebKey | null {
// Try multi-key format first
const keysJson = Deno.env.get("ADMIN_KEYS_JSON");
if (keysJson) {
try {
const keys = JSON.parse(keysJson);
if (keys[keyId]) {
return keys[keyId];
}
} catch {
console.error("Failed to parse ADMIN_KEYS_JSON");
}
}
// Fall back to single-key format
const singleKeyJson = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
if (singleKeyJson) {
try {
return JSON.parse(singleKeyJson);
} catch {
console.error("Failed to parse ADMIN_PRIVATE_KEY_JWK");
}
}
return null;
}
// Crypto helper functions
async function unwrapWithAdminKey(
wrappedKeyBase64: string,
adminPrivateKeyJwk: JsonWebKey
): Promise<ArrayBuffer> {
const privateKey = await crypto.subtle.importKey(
"jwk",
adminPrivateKeyJwk,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["decrypt"]
);
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
return await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
privateKey,
wrappedKey
);
}
async function wrapForRecipient(
rawAesKey: ArrayBuffer,
recipientPublicKeyJwk: JsonWebKey
): Promise<string> {
// Sanitize JWK to ensure Base64URL encoding for n and e
const sanitizedJwk = { ...recipientPublicKeyJwk };
if (sanitizedJwk.n) {
sanitizedJwk.n = toBase64Url(sanitizedJwk.n);
}
if (sanitizedJwk.e) {
sanitizedJwk.e = toBase64Url(sanitizedJwk.e);
}
const publicKey = await crypto.subtle.importKey(
"jwk",
sanitizedJwk,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["encrypt"]
);
const wrappedKey = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
publicKey,
rawAesKey
);
return arrayBufferToBase64(wrappedKey);
}
function toBase64Url(base64: string): string {
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
@@ -0,0 +1,289 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
/**
* Repairs a message encrypted with a stale recipient key.
*
* When a message was encrypted while offline and the recipient's key changed
* before sync, the recipient can't decrypt. This function uses the admin key
* to re-wrap the AES key for the recipient's current public key.
*/
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// Get user from JWT
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Missing authorization header" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const { data: { user }, error: authError } = await supabaseAdmin.auth.getUser(
authHeader.replace("Bearer ", "")
);
if (authError || !user) {
return new Response(
JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Parse request body
const { message_id } = await req.json();
if (!message_id) {
return new Response(
JSON.stringify({ error: "Missing message_id" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get user's current public key
const { data: profile, error: profileError } = await supabaseAdmin
.from("profiles")
.select("id, couple_id, public_key_jwk")
.eq("id", user.id)
.maybeSingle();
if (profileError || !profile) {
return new Response(
JSON.stringify({ error: "Profile not found" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!profile.public_key_jwk) {
return new Response(
JSON.stringify({ error: "User has no public key" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get the message
const { data: message, error: messageError } = await supabaseAdmin
.from("messages")
.select("id, user_id, match_id, version, keys_metadata")
.eq("id", message_id)
.maybeSingle();
if (messageError || !message) {
return new Response(
JSON.stringify({ error: "Message not found" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Verify this is a v2 message
if (message.version !== 2) {
return new Response(
JSON.stringify({ error: "Message is not encrypted (v1)" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Verify user has access to this message (via match -> couple)
const { data: match } = await supabaseAdmin
.from("matches")
.select("couple_id")
.eq("id", message.match_id)
.maybeSingle();
if (!match || match.couple_id !== profile.couple_id) {
return new Response(
JSON.stringify({ error: "Access denied" }),
{ status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Determine if user is sender or recipient
const isSender = message.user_id === user.id;
const metadata = message.keys_metadata as {
sender_wrapped_key: string;
recipient_wrapped_key?: string;
admin_wrapped_key: string;
admin_key_id: string;
algorithm: string;
key_wrap_algorithm: string;
pending_recipient?: boolean;
};
// Get admin private key - supports multi-key lookup
const adminPrivateKeyJwk = getAdminPrivateKey(metadata.admin_key_id);
if (!adminPrivateKeyJwk) {
return new Response(
JSON.stringify({ error: "Admin key not found for this message" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Unwrap AES key using admin key
const rawAesKey = await unwrapWithAdminKey(
metadata.admin_wrapped_key,
adminPrivateKeyJwk
);
// Re-wrap with user's current public key
const newWrappedKey = await wrapForRecipient(
rawAesKey,
profile.public_key_jwk
);
// Update the appropriate field
const updatedMetadata = { ...metadata };
if (isSender) {
updatedMetadata.sender_wrapped_key = newWrappedKey;
} else {
updatedMetadata.recipient_wrapped_key = newWrappedKey;
updatedMetadata.pending_recipient = false;
}
const { error: updateError } = await supabaseAdmin
.from("messages")
.update({ keys_metadata: updatedMetadata })
.eq("id", message.id);
if (updateError) {
console.error(`Failed to update message ${message.id}:`, updateError);
return new Response(
JSON.stringify({ error: "Failed to update message" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
console.log(`Repaired stale key for message ${message.id}, user ${user.id} (${isSender ? 'sender' : 'recipient'})`);
return new Response(
JSON.stringify({ success: true, message_id: message.id }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
/**
* Get admin private key by ID.
* Supports multi-key format: ADMIN_KEYS_JSON = { "uuid1": {...}, "uuid2": {...} }
* Falls back to single-key format: ADMIN_PRIVATE_KEY_JWK = {...}
*/
function getAdminPrivateKey(keyId: string): JsonWebKey | null {
// Try multi-key format first
const keysJson = Deno.env.get("ADMIN_KEYS_JSON");
if (keysJson) {
try {
const keys = JSON.parse(keysJson);
if (keys[keyId]) {
return keys[keyId];
}
} catch {
console.error("Failed to parse ADMIN_KEYS_JSON");
}
}
// Fall back to single-key format
const singleKeyJson = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
if (singleKeyJson) {
try {
return JSON.parse(singleKeyJson);
} catch {
console.error("Failed to parse ADMIN_PRIVATE_KEY_JWK");
}
}
return null;
}
// Crypto helper functions
async function unwrapWithAdminKey(
wrappedKeyBase64: string,
adminPrivateKeyJwk: JsonWebKey
): Promise<ArrayBuffer> {
const privateKey = await crypto.subtle.importKey(
"jwk",
adminPrivateKeyJwk,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["decrypt"]
);
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
return await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
privateKey,
wrappedKey
);
}
async function wrapForRecipient(
rawAesKey: ArrayBuffer,
recipientPublicKeyJwk: JsonWebKey
): Promise<string> {
// Sanitize JWK to ensure Base64URL encoding for n and e
const sanitizedJwk = { ...recipientPublicKeyJwk };
if (sanitizedJwk.n) {
sanitizedJwk.n = toBase64Url(sanitizedJwk.n);
}
if (sanitizedJwk.e) {
sanitizedJwk.e = toBase64Url(sanitizedJwk.e);
}
const publicKey = await crypto.subtle.importKey(
"jwk",
sanitizedJwk,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["encrypt"]
);
const wrappedKey = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
publicKey,
rawAesKey
);
return arrayBufferToBase64(wrappedKey);
}
function toBase64Url(base64: string): string {
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
@@ -0,0 +1,29 @@
-- Add E2EE support to messages table
-- This migration adds columns for end-to-end encryption of chat messages
-- Version indicator: 1 = plaintext (legacy), 2 = E2EE
ALTER TABLE messages ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1;
-- Encrypted message content (base64 encoded ciphertext)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encrypted_content TEXT;
-- Initialization vector for AES-GCM (base64 encoded, 12 bytes)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encryption_iv TEXT;
-- Triple-wrapped encryption keys and metadata
ALTER TABLE messages ADD COLUMN IF NOT EXISTS keys_metadata JSONB;
-- Add comment for documentation
COMMENT ON COLUMN messages.version IS
'Encryption version: 1 = plaintext (legacy), 2 = E2EE with triple-wrapped keys';
-- Constraint: v2 messages must have encryption fields
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'e2ee_fields_required'
) THEN
ALTER TABLE messages ADD CONSTRAINT e2ee_fields_required
CHECK (version = 1 OR (version = 2 AND encryption_iv IS NOT NULL AND keys_metadata IS NOT NULL));
END IF;
END $$;
@@ -0,0 +1,12 @@
-- Add E2EE public key to profiles table
-- This migration adds a column to store user's RSA public key for E2EE
-- RSA public key in JWK format (JSON Web Key)
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS public_key_jwk JSONB;
-- Index for efficient lookups when encrypting for partner
CREATE INDEX IF NOT EXISTS idx_profiles_couple_id_public_key ON profiles(couple_id)
WHERE public_key_jwk IS NOT NULL;
COMMENT ON COLUMN profiles.public_key_jwk IS
'RSA-2048 public key in JWK format for E2EE. Private key stored on device only.';
@@ -0,0 +1,70 @@
-- Create master_keys table for admin E2EE access
-- This migration creates the table to store admin RSA public keys for moderation
CREATE TABLE IF NOT EXISTS master_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_name TEXT UNIQUE NOT NULL,
public_key_jwk JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
rotated_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT true
);
-- Add table comment
COMMENT ON TABLE master_keys IS
'Stores admin RSA public keys for E2EE moderation access. Private key stored securely in environment variables.';
-- Enable RLS
ALTER TABLE master_keys ENABLE ROW LEVEL SECURITY;
-- Super admins can view master keys
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE policyname = 'Super admins can view master keys' AND tablename = 'master_keys'
) THEN
CREATE POLICY "Super admins can view master keys"
ON master_keys FOR SELECT
TO authenticated
USING (is_super_admin(auth.uid()));
END IF;
END $$;
-- Super admins can insert master keys
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE policyname = 'Super admins can insert master keys' AND tablename = 'master_keys'
) THEN
CREATE POLICY "Super admins can insert master keys"
ON master_keys FOR INSERT
TO authenticated
WITH CHECK (is_super_admin(auth.uid()));
END IF;
END $$;
-- Super admins can update master keys
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE policyname = 'Super admins can update master keys' AND tablename = 'master_keys'
) THEN
CREATE POLICY "Super admins can update master keys"
ON master_keys FOR UPDATE
TO authenticated
USING (is_super_admin(auth.uid()));
END IF;
END $$;
-- Allow all authenticated users to read active master key public keys (needed for encryption)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE policyname = 'Anyone can read active master key public keys' AND tablename = 'master_keys'
) THEN
CREATE POLICY "Anyone can read active master key public keys"
ON master_keys FOR SELECT
TO authenticated
USING (is_active = true);
END IF;
END $$;
@@ -0,0 +1,39 @@
-- Migration: Sanitize public keys in profiles table
-- Description: Replaces base64 url unsafe characters (+, /) with safe ones (-, _) and removes padding (=) in 'n' and 'e' fields of public_key_jwk
DO $$
DECLARE
r RECORD;
new_n TEXT;
new_e TEXT;
original_n TEXT;
original_e TEXT;
BEGIN
FOR r IN SELECT id, public_key_jwk FROM profiles WHERE public_key_jwk IS NOT NULL LOOP
original_n := r.public_key_jwk->>'n';
original_e := r.public_key_jwk->>'e';
IF original_n IS NOT NULL AND original_e IS NOT NULL THEN
-- Sanitize 'n'
new_n := REPLACE(original_n, '+', '-');
new_n := REPLACE(new_n, '/', '_');
new_n := REPLACE(new_n, '=', '');
-- Sanitize 'e'
new_e := REPLACE(original_e, '+', '-');
new_e := REPLACE(new_e, '/', '_');
new_e := REPLACE(new_e, '=', '');
-- Update if changed
IF new_n <> original_n OR new_e <> original_e THEN
UPDATE profiles
SET public_key_jwk = jsonb_set(
jsonb_set(public_key_jwk, '{n}', to_jsonb(new_n)),
'{e}',
to_jsonb(new_e)
)
WHERE id = r.id;
END IF;
END IF;
END LOOP;
END $$;
@@ -0,0 +1,15 @@
-- Update the content_or_media constraint to allow E2EE messages
-- E2EE messages have encrypted_content instead of content
ALTER TABLE public.messages DROP CONSTRAINT IF EXISTS content_or_media;
ALTER TABLE public.messages ADD CONSTRAINT content_or_media CHECK (
-- v1 (plaintext): must have content or media_path
(version = 1 AND (content IS NOT NULL OR media_path IS NOT NULL))
OR
-- v2 (E2EE): must have encrypted_content or media_path
(version = 2 AND (encrypted_content IS NOT NULL OR media_path IS NOT NULL))
OR
-- Legacy messages without version: require content or media_path
(version IS NULL AND (content IS NOT NULL OR media_path IS NOT NULL))
);
@@ -0,0 +1,66 @@
-- Add moderation columns to messages table
ALTER TABLE messages
ADD COLUMN moderation_status text DEFAULT 'unmoderated' CHECK (moderation_status IN ('unmoderated', 'safe', 'flagged')),
ADD COLUMN flag_reason text;
-- Add classifier configuration to ai_config table
ALTER TABLE ai_config
ADD COLUMN classifier_enabled boolean DEFAULT true,
ADD COLUMN classifier_model text DEFAULT 'openai/gpt-4o',
ADD COLUMN classifier_prompt text DEFAULT 'You are a content moderator for a couples app. Your task is to analyze the following message (text and/or image) and determine if it contains ILLEGAL or DANGEROUS content.
Sexual content (nudity, sexual acts, intimate conversations) is ALLOWED and should be marked as SAFE.
You must FLAG content ONLY if it falls into these categories:
1. Child Sexual Abuse Material (CSAM) or any depiction of minors in a sexual context.
2. Non-consensual sexual content (revenge porn, rape, assault).
3. Promotion of self-harm or suicide.
4. Human trafficking, terrorism, or extreme violence/gore.
5. Illegal drugs or weapons trafficking.
If the content is safe (including consensual adult sexual content), respond with {"status": "safe"}.
If the content is illegal/dangerous, respond with {"status": "flagged", "reason": "Brief explanation"}.
Response must be valid JSON.';
-- Function to trigger classification
CREATE OR REPLACE FUNCTION trigger_classify_message()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_classifier_enabled boolean;
BEGIN
-- Check if classifier is enabled
SELECT classifier_enabled INTO v_classifier_enabled
FROM public.ai_config
LIMIT 1;
-- If enabled (or null which defaults to true conceptually, but let's be strict), trigger function
IF v_classifier_enabled IS TRUE THEN
PERFORM net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/classify-message',
headers := jsonb_build_object(
'Content-Type', 'application/json'
),
body := jsonb_build_object(
'message_id', NEW.id
)
);
END IF;
RETURN NEW;
END;
$$;
-- Create trigger on messages table
CREATE TRIGGER on_message_created_classify
AFTER INSERT ON messages
FOR EACH ROW
EXECUTE FUNCTION trigger_classify_message();
-- Add comments
COMMENT ON COLUMN messages.moderation_status IS 'Content moderation status: unmoderated, safe, or flagged.';
COMMENT ON COLUMN messages.flag_reason IS 'Reason for flagging the content (if status is flagged).';
@@ -0,0 +1,32 @@
-- Add category column to messages table
ALTER TABLE messages
ADD COLUMN category text;
COMMENT ON COLUMN messages.category IS 'Content category: Neutral, Romantic, Playful, Explicit, etc.';
-- Update the default classifier prompt in ai_config
UPDATE ai_config
SET classifier_prompt = 'You are a content moderator for a couples app. Your task is to analyze the following message (text and/or image) and determine if it contains ILLEGAL or DANGEROUS content, and also categorize the tone/nature of the message.
1. SAFETY CHECK:
Sexual content (nudity, sexual acts, intimate conversations) is ALLOWED and should be marked as SAFE.
You must FLAG content ONLY if it falls into these categories:
- Child Sexual Abuse Material (CSAM) or any depiction of minors in a sexual context.
- Non-consensual sexual content (revenge porn, rape, assault).
- Promotion of self-harm or suicide.
- Human trafficking, terrorism, or extreme violence/gore.
- Illegal drugs or weapons trafficking.
2. CATEGORIZATION:
Classify the content into one of these categories:
- "Neutral": General conversation, logistics, day-to-day, non-sexual.
- "Romantic": Affectionate, emotional connection, loving, sweet.
- "Playful": Flirty, teasing, fun, joking.
- "Explicit": Sexual, intimate, NSFW, steamy.
Response Format (JSON):
{
"status": "safe" | "flagged",
"reason": "Explanation if flagged, or null if safe",
"category": "Neutral" | "Romantic" | "Playful" | "Explicit"
}';
@@ -0,0 +1,52 @@
-- Add deleted_at column to messages table for "delete for everyone"
ALTER TABLE messages ADD COLUMN deleted_at TIMESTAMPTZ;
-- Create message_deletions table for "delete for self" (per-user deletions)
CREATE TABLE message_deletions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
deleted_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(message_id, user_id)
);
-- Index for efficient lookup when filtering messages
CREATE INDEX idx_message_deletions_user_message ON message_deletions(user_id, message_id);
CREATE INDEX idx_messages_deleted_at ON messages(deleted_at) WHERE deleted_at IS NOT NULL;
-- Enable RLS on message_deletions
ALTER TABLE message_deletions ENABLE ROW LEVEL SECURITY;
-- RLS Policies for message_deletions
-- Users can view their own deletions (needed for filtering)
CREATE POLICY "Users can view own deletions"
ON message_deletions FOR SELECT
USING (user_id = auth.uid());
-- Users can insert their own deletions (for messages in their matches)
CREATE POLICY "Users can delete messages for self"
ON message_deletions FOR INSERT
WITH CHECK (
user_id = auth.uid() AND
EXISTS (
SELECT 1 FROM messages m
JOIN matches ma ON ma.id = m.match_id
JOIN profiles p ON p.couple_id = ma.couple_id
WHERE m.id = message_deletions.message_id
AND p.id = auth.uid()
)
);
-- Update messages RLS policy to allow authors to set deleted_at
CREATE POLICY "Authors can soft delete messages for everyone"
ON messages FOR UPDATE
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- Add to realtime for live updates
ALTER PUBLICATION supabase_realtime ADD TABLE message_deletions;
-- Comments for documentation
COMMENT ON COLUMN messages.deleted_at IS 'Timestamp when message was deleted for everyone (author-only action). Shows "Message deleted" to all users.';
COMMENT ON TABLE message_deletions IS 'Per-user message deletions for "delete for self" functionality. Message still visible to other user.';
File diff suppressed because it is too large. Load diff
+3325 -1
View File
File diff suppressed because it is too large. Load diff
+60
View File
@@ -8,6 +8,8 @@ export interface Profile {
couple_id: string | null; couple_id: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
/** RSA public key in JWK format for E2EE */
public_key_jwk?: Record<string, unknown> | null;
} }
// Couple pairing // Couple pairing
@@ -79,3 +81,61 @@ export interface PackProgress {
answered_questions: number; answered_questions: number;
matches_count: number; matches_count: number;
} }
// E2EE Keys Metadata for encrypted messages
export interface KeysMetadata {
/** AES key encrypted with sender's RSA public key (base64) */
sender_wrapped_key: string;
/** AES key encrypted with recipient's RSA public key (base64) */
recipient_wrapped_key?: string;
/** AES key encrypted with admin's RSA public key (base64) */
admin_wrapped_key: string;
/** UUID of the master_keys record used */
admin_key_id: string;
/** Symmetric encryption algorithm */
algorithm: 'AES-256-GCM';
/** Key wrapping algorithm */
key_wrap_algorithm: 'RSA-OAEP-SHA256';
/** True if recipient key needs to be added */
pending_recipient?: boolean;
}
// Chat message
export interface Message {
id: string;
match_id: string;
user_id: string;
content: string | null;
created_at: string;
read_at: string | null;
delivered_at: string | null;
media_path: string | null;
media_type: 'image' | 'video' | null;
media_expires_at: string | null;
media_expired: boolean;
media_viewed_at: string | null;
/** Encryption version: 1 = plaintext (legacy), 2 = E2EE */
version: number | null;
/** Encrypted message content (base64, for v2 messages) */
encrypted_content: string | null;
/** Initialization vector for AES-GCM (base64, for v2 messages) */
encryption_iv: string | null;
/** Triple-wrapped encryption keys metadata (for v2 messages) */
keys_metadata: KeysMetadata | null;
/** Content moderation status: safe, flagged, unmoderated */
moderation_status?: 'safe' | 'flagged' | 'unmoderated' | null;
/** Reason for flagging */
flag_reason?: string | null;
/** Content category: Neutral, Romantic, Playful, Explicit, etc. */
category?: 'Neutral' | 'Romantic' | 'Playful' | 'Explicit' | string | null;
}
// Master key for admin E2EE access
export interface MasterKey {
id: string;
key_name: string;
public_key_jwk: Record<string, unknown>;
is_active: boolean;
created_at: string;
rotated_at: string | null;
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Admin Key Generation Script for E2EE
*
* Run this to generate an admin RSA-2048 key pair for E2EE.
*
* Usage:
* node scripts/generate-admin-keys.mjs # Generate new key
* node scripts/generate-admin-keys.mjs --rotate # Generate for rotation (shows migration steps)
*
* Output:
* - PUBLIC KEY: Store in master_keys table via Supabase dashboard or migration
* - PRIVATE KEY: Store in environment variables for Supabase Edge Functions
*
* IMPORTANT: Never commit the private key to version control!
*/
import { webcrypto, randomUUID } from 'crypto';
const isRotation = process.argv.includes('--rotate');
async function generateAdminKeyPair() {
console.log('Generating RSA-2048 key pair for E2EE admin access...\n');
// Generate a UUID for this key (used in keys_metadata.admin_key_id)
const keyId = randomUUID();
const keyName = `e2ee_admin_key_${Date.now()}`;
const keyPair = await webcrypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]), // 65537
hash: 'SHA-256',
},
true, // extractable
['encrypt', 'decrypt']
);
const publicKeyJwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey);
const privateKeyJwk = await webcrypto.subtle.exportKey('jwk', keyPair.privateKey);
console.log('='.repeat(80));
console.log(`KEY ID: ${keyId}`);
console.log('='.repeat(80));
console.log('\nSave this ID - it will be stored in messages and used for key lookup.\n');
console.log('='.repeat(80));
console.log('PUBLIC KEY (store in master_keys table)');
console.log('='.repeat(80));
if (isRotation) {
console.log('\n1. First, mark old key as inactive:\n');
console.log(`UPDATE master_keys SET is_active = false WHERE is_active = true;`);
console.log('\n2. Then insert the new key:\n');
} else {
console.log('\nRun this SQL in Supabase dashboard or create a migration:\n');
}
console.log(`INSERT INTO master_keys (id, key_name, public_key_jwk, is_active)
VALUES ('${keyId}', '${keyName}', '${JSON.stringify(publicKeyJwk)}'::jsonb, true);`);
console.log('\n');
console.log('='.repeat(80));
console.log('PRIVATE KEY - SINGLE KEY FORMAT (backwards compatible)');
console.log('='.repeat(80));
console.log('\nFor first-time setup, add this to Supabase Edge Function secrets:\n');
console.log('Key name: ADMIN_PRIVATE_KEY_JWK');
console.log('Value (single line):');
console.log(JSON.stringify(privateKeyJwk));
console.log('\n');
console.log('='.repeat(80));
console.log('PRIVATE KEY - MULTI-KEY FORMAT (for key rotation)');
console.log('='.repeat(80));
console.log('\nFor key rotation support, use ADMIN_KEYS_JSON instead:\n');
console.log('Key name: ADMIN_KEYS_JSON');
console.log('Value format: { "key-id-1": {...jwk...}, "key-id-2": {...jwk...} }');
console.log('\nFor this new key, add to your existing ADMIN_KEYS_JSON:');
console.log(`"${keyId}": ${JSON.stringify(privateKeyJwk)}`);
if (isRotation) {
console.log('\n');
console.log('='.repeat(80));
console.log('KEY ROTATION CHECKLIST');
console.log('='.repeat(80));
console.log(`
1. [ ] Run the SQL above to insert new public key and deactivate old one
2. [ ] Add the new private key to ADMIN_KEYS_JSON (keep the old key!)
3. [ ] Deploy updated edge functions
4. [ ] Test that new messages use the new key
5. [ ] Test that old messages can still be decrypted
6. [ ] (Optional) After 90+ days, remove old key from ADMIN_KEYS_JSON
`);
}
console.log('\n');
console.log('='.repeat(80));
console.log('IMPORTANT SECURITY NOTES');
console.log('='.repeat(80));
console.log(`
1. NEVER commit the private key to version control
2. Store the private key securely in Supabase Edge Function secrets
3. The public key can be safely stored in the database
4. Keep a secure backup of the private key offline
5. If the private key is compromised, rotate immediately
6. When rotating keys, keep old private keys until all messages are migrated
`);
// Also output a JSON file for backup (to stderr so it can be redirected)
console.error('\n--- KEY BACKUP (redirect stderr to save) ---');
console.error(JSON.stringify({
keyId,
keyName,
publicKeyJwk,
privateKeyJwk,
generatedAt: new Date().toISOString(),
}, null, 2));
}
generateAdminKeyPair().catch(console.error);