mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
Remove E2EE and switch to plaintext messaging
🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
1 parent
43fa7033dc
commit
c48936686e
46 files changed
+738
-6874
No files matched your search
@@ -16,7 +16,7 @@ import type { ReportReason } from "./types";
|
||||
|
||||
import { supabase } from "../../lib/supabase";
|
||||
import { useAuthStore, useMessageStore } from "../../store";
|
||||
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useEncryptedSend, useMediaSaver } from "../../hooks";
|
||||
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useMediaSaver } from "../../hooks";
|
||||
import { Database } from "../../types/supabase";
|
||||
import { GradientBackground } from "../../components/ui";
|
||||
import { colors, gradients, radius, spacing, typography } from "../../theme";
|
||||
@@ -34,7 +34,7 @@ export const ChatScreen: React.FC = () => {
|
||||
const matchId = id as string;
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { user, partner, encryptionKeys } = useAuthStore();
|
||||
const { user, partner } = useAuthStore();
|
||||
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
|
||||
const [match, setMatch] = useState<any>(null);
|
||||
const [inputText, setInputText] = useState("");
|
||||
@@ -74,23 +74,6 @@ export const ChatScreen: React.FC = () => {
|
||||
onNewMessage: clearTypingIndicator,
|
||||
});
|
||||
|
||||
// E2EE encryption hook for sending messages
|
||||
const { encryptMessage, isE2EEAvailable, isAdminKeyReady, isEncrypting } = useEncryptedSend();
|
||||
const secureReady = isE2EEAvailable && isAdminKeyReady;
|
||||
const recoveryMessage = (() => {
|
||||
if (!encryptionKeys.isRecovering) return null;
|
||||
switch (encryptionKeys.recoveryReason) {
|
||||
case 'pending-recipient':
|
||||
return 'Finishing security setup for older messages...';
|
||||
case 'keys-unavailable':
|
||||
return 'Setting up secure keys...';
|
||||
case 'stale-key':
|
||||
case 'rotation':
|
||||
return 'Updating security for this device...';
|
||||
default:
|
||||
return 'Updating security...';
|
||||
}
|
||||
})();
|
||||
|
||||
// Message actions hook for deletion and reporting
|
||||
const { showDeleteOptions, deleteForSelf } = useMessageActions({
|
||||
@@ -196,35 +179,18 @@ export const ChatScreen: React.FC = () => {
|
||||
const handleSend = async () => {
|
||||
if (!inputText.trim()) return;
|
||||
if (!user) return;
|
||||
if (!secureReady) return;
|
||||
if (isEncrypting) return;
|
||||
|
||||
const content = inputText.trim();
|
||||
setInputText("");
|
||||
|
||||
try {
|
||||
// Encrypt the message (secure-only)
|
||||
console.log(`[E2EE Send] isE2EEAvailable=${isE2EEAvailable}`);
|
||||
const encryptedPayload = await encryptMessage(content);
|
||||
console.log(`[E2EE Send] encryptedPayload=`, {
|
||||
version: encryptedPayload.version,
|
||||
encrypted_content_length: encryptedPayload.encrypted_content.length,
|
||||
encryption_iv_length: encryptedPayload.encryption_iv.length,
|
||||
keys_metadata: encryptedPayload.keys_metadata,
|
||||
});
|
||||
|
||||
// Send encrypted message (v2)
|
||||
const { error, data } = await supabase.from("messages").insert({
|
||||
// Send plaintext message (v1) - protected by TLS in transit and RLS policies
|
||||
const { error } = 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 });
|
||||
content: content,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
Alert.alert("Error", "Failed to send message");
|
||||
@@ -234,26 +200,12 @@ export const ChatScreen: React.FC = () => {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error sending message:", err);
|
||||
// Show the actual error message if it's user-friendly (from encryption)
|
||||
const errorMessage = err instanceof Error && err.message
|
||||
? err.message
|
||||
: "Failed to send message";
|
||||
|
||||
// Map technical/unknown errors to friendly ones
|
||||
let friendlyMessage = errorMessage;
|
||||
if (errorMessage.includes('Failed to send') && !errorMessage.includes('connection')) {
|
||||
friendlyMessage = "Couldn't send secure message. Please check your connection.";
|
||||
} else if (errorMessage.includes('verification failed')) {
|
||||
friendlyMessage = "Security check failed. Please restart the app.";
|
||||
}
|
||||
|
||||
Alert.alert("Delivery Failed", friendlyMessage);
|
||||
Alert.alert("Delivery Failed", "Couldn't send message. Please check your connection.");
|
||||
setInputText(content);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickMedia = async () => {
|
||||
if (!secureReady) return;
|
||||
const result = await pickMedia();
|
||||
if (result) {
|
||||
uploadMedia(result.uri, result.mediaType);
|
||||
@@ -261,7 +213,6 @@ export const ChatScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleTakePhoto = async () => {
|
||||
if (!secureReady) return;
|
||||
const result = await takePhoto();
|
||||
if (result) {
|
||||
uploadMedia(result.uri, 'image');
|
||||
@@ -269,7 +220,6 @@ export const ChatScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleRecordVideo = async () => {
|
||||
if (!secureReady) return;
|
||||
const result = await recordVideo();
|
||||
if (result) {
|
||||
uploadMedia(result.uri, 'video');
|
||||
@@ -335,13 +285,6 @@ export const ChatScreen: React.FC = () => {
|
||||
onBack={() => router.push('/(app)/matches')}
|
||||
/>
|
||||
|
||||
{recoveryMessage && (
|
||||
<View style={styles.securityBanner}>
|
||||
<Ionicons name="shield-checkmark-outline" size={16} color={ACCENT} />
|
||||
<Text style={styles.securityBannerText}>{recoveryMessage}</Text>
|
||||
<ActivityIndicator size="small" color={ACCENT} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
@@ -370,17 +313,9 @@ export const ChatScreen: React.FC = () => {
|
||||
{/* Top border glow */}
|
||||
<View style={styles.inputWrapperBorder} />
|
||||
|
||||
{!secureReady && (
|
||||
<View style={styles.secureStatus}>
|
||||
<Ionicons name="lock-closed-outline" size={14} color={colors.textSecondary} />
|
||||
<Text style={styles.secureStatusText}>Finishing security setup...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<InputBar
|
||||
inputText={inputText}
|
||||
uploading={uploading}
|
||||
secureReady={secureReady}
|
||||
onChangeText={handleTyping}
|
||||
onSend={handleSend}
|
||||
onPickMedia={handlePickMedia}
|
||||
@@ -585,41 +520,4 @@ const styles = StyleSheet.create({
|
||||
height: 1,
|
||||
backgroundColor: `${ACCENT_RGBA}0.15)`,
|
||||
},
|
||||
securityBanner: {
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.12)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(212, 175, 55, 0.25)',
|
||||
},
|
||||
securityBannerText: {
|
||||
flex: 1,
|
||||
color: colors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
secureStatus: {
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.glass.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
secureStatusText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
});
|
||||
@@ -14,7 +14,6 @@ import { colors, gradients, spacing, radius, typography } from '../../../theme';
|
||||
export interface InputBarProps {
|
||||
inputText: string;
|
||||
uploading: boolean;
|
||||
secureReady: boolean;
|
||||
onChangeText: (text: string) => void;
|
||||
onSend: () => void;
|
||||
onPickMedia: () => void;
|
||||
@@ -31,7 +30,6 @@ const ACCENT = colors.premium.gold;
|
||||
export function InputBar({
|
||||
inputText,
|
||||
uploading,
|
||||
secureReady,
|
||||
onChangeText,
|
||||
onSend,
|
||||
onPickMedia,
|
||||
@@ -65,10 +63,8 @@ export function InputBar({
|
||||
transform: [{ rotate: `${buttonRotation.value}deg` }],
|
||||
}));
|
||||
|
||||
const sendDisabled = !inputText.trim() || !secureReady;
|
||||
const mediaDisabled = uploading || !secureReady;
|
||||
// Determine if send is disabled specifically because of security (not empty input)
|
||||
const waitingForSecurity = !secureReady && inputText.trim().length > 0;
|
||||
const sendDisabled = !inputText.trim();
|
||||
const mediaDisabled = uploading;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -153,8 +149,7 @@ export function InputBar({
|
||||
disabled={sendDisabled}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={waitingForSecurity ? "Send message - securing connection" : "Send message"}
|
||||
accessibilityHint={waitingForSecurity ? "Please wait while we secure your connection" : undefined}
|
||||
accessibilityLabel="Send message"
|
||||
testID="chat-input-send"
|
||||
>
|
||||
<LinearGradient
|
||||
@@ -173,8 +168,8 @@ export function InputBar({
|
||||
/>
|
||||
)}
|
||||
<Ionicons
|
||||
name={waitingForSecurity ? "lock-closed" : "send"}
|
||||
size={waitingForSecurity ? 16 : 18}
|
||||
name="send"
|
||||
size={18}
|
||||
color={sendDisabled ? colors.textTertiary : colors.text}
|
||||
/>
|
||||
</LinearGradient>
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
/**
|
||||
* MessageContent - Renders message content (text, images, videos)
|
||||
* Handles media reveal logic, expired media, and loading states.
|
||||
* Supports both v1 (plaintext) and v2 (E2EE encrypted) messages.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator, Alert } from 'react-native';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { colors, gradients, spacing, radius } from '../../../theme';
|
||||
import { MessageMeta } from './MessageMeta';
|
||||
import { ChatVideoPlayer } from './ChatVideoPlayer';
|
||||
import { useDecryptedMessage, useDecryptedMedia } from '../../../hooks';
|
||||
import type { KeysMetadata } from '../../../lib/encryption';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import type { Message } from '../types';
|
||||
|
||||
const ACCENT = colors.premium.gold;
|
||||
@@ -52,61 +50,41 @@ export function MessageContent({
|
||||
);
|
||||
}
|
||||
|
||||
// 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 isViewed = !!item.media_viewed_at;
|
||||
const isRecipientHidden = !isMe && !isViewed;
|
||||
|
||||
// Use decryption hook for E2EE media (images/videos)
|
||||
const {
|
||||
uri: mediaUri,
|
||||
isDecrypting: isDecryptingMedia,
|
||||
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,
|
||||
});
|
||||
// Get signed URL for media
|
||||
const [mediaUri, setMediaUri] = useState<string | null>(null);
|
||||
const [mediaLoading, setMediaLoading] = useState(false);
|
||||
const [mediaError, setMediaError] = useState(false);
|
||||
|
||||
const isPendingMedia = mediaErrorCode === 'E2EE_PENDING_RECIPIENT_KEY';
|
||||
useEffect(() => {
|
||||
if (!item.media_path || isRecipientHidden) {
|
||||
setMediaUri(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const showSecureMediaInfo = () => {
|
||||
Alert.alert(
|
||||
'Secure media',
|
||||
"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”.",
|
||||
[
|
||||
{ text: 'OK', style: 'cancel' },
|
||||
{ text: 'Try again', onPress: () => retryMedia() },
|
||||
]
|
||||
);
|
||||
};
|
||||
const fetchMediaUrl = async () => {
|
||||
setMediaLoading(true);
|
||||
setMediaError(false);
|
||||
try {
|
||||
const { data, error } = await supabase.storage
|
||||
.from('chat-media')
|
||||
.createSignedUrl(item.media_path!, 3600); // 1 hour expiry
|
||||
|
||||
if (error) throw error;
|
||||
setMediaUri(data.signedUrl);
|
||||
} catch (err) {
|
||||
console.error('Failed to get media URL:', err);
|
||||
setMediaError(true);
|
||||
} finally {
|
||||
setMediaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMediaUrl();
|
||||
}, [item.media_path, isRecipientHidden]);
|
||||
|
||||
// Handle expired videos
|
||||
const isExpired = !!(item as any).media_expired;
|
||||
@@ -135,8 +113,7 @@ export function MessageContent({
|
||||
// Handle media (images and videos)
|
||||
if (item.media_path || isExpired) {
|
||||
const canOpenFullScreen = (isMe || isViewed) && !isVideo; // Only images can go fullscreen
|
||||
const hasMediaError = !!mediaError;
|
||||
const isLoading = isDecryptingMedia || (!mediaUri && !hasMediaError && !isRecipientHidden);
|
||||
const isLoading = mediaLoading || (!mediaUri && !mediaError && !isRecipientHidden);
|
||||
|
||||
// Video content
|
||||
if (isVideo) {
|
||||
@@ -166,26 +143,11 @@ export function MessageContent({
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
) : mediaUri || hasMediaError ? (
|
||||
) : mediaUri || mediaError ? (
|
||||
<ChatVideoPlayer
|
||||
signedUrl={mediaUri}
|
||||
storagePath={item.media_path!}
|
||||
urlError={hasMediaError}
|
||||
urlError={mediaError}
|
||||
onFullScreen={onVideoFullScreen}
|
||||
/>
|
||||
) : (
|
||||
@@ -209,17 +171,13 @@ export function MessageContent({
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
activeOpacity={isPendingMedia || canOpenFullScreen ? 0.8 : 1}
|
||||
activeOpacity={canOpenFullScreen ? 0.8 : 1}
|
||||
onPress={() => {
|
||||
if (isPendingMedia) {
|
||||
showSecureMediaInfo();
|
||||
return;
|
||||
}
|
||||
if (canOpenFullScreen && mediaUri) {
|
||||
onImagePress(mediaUri);
|
||||
}
|
||||
}}
|
||||
disabled={!(isPendingMedia || (canOpenFullScreen && !!mediaUri))}
|
||||
disabled={!(canOpenFullScreen && !!mediaUri)}
|
||||
>
|
||||
{isRecipientHidden ? (
|
||||
<TouchableOpacity
|
||||
@@ -252,20 +210,7 @@ export function MessageContent({
|
||||
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 ? (
|
||||
) : mediaError ? (
|
||||
<View style={[styles.messageImage, styles.messageImageError]}>
|
||||
<Ionicons name="image-outline" size={32} color={colors.textSecondary} />
|
||||
<Text style={styles.messageImageErrorText}>Failed to load image</Text>
|
||||
@@ -288,73 +233,10 @@ export function MessageContent({
|
||||
);
|
||||
}
|
||||
|
||||
// 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}
|
||||
onLongPress={onLongPress}
|
||||
delayLongPress={300}
|
||||
>
|
||||
<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} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (decryptError) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={retryDecrypt}
|
||||
onLongPress={onLongPress}
|
||||
delayLongPress={300}
|
||||
>
|
||||
<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>;
|
||||
};
|
||||
|
||||
// Text message - display content directly
|
||||
return (
|
||||
<>
|
||||
{renderText()}
|
||||
<Text style={styles.messageText}>{item.content ?? 'Message unavailable'}</Text>
|
||||
<MessageMeta item={item} isMe={isMe} />
|
||||
</>
|
||||
);
|
||||
@@ -376,61 +258,12 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
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: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: radius.md,
|
||||
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: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { Video as VideoCompressor } from 'react-native-compressor';
|
||||
@@ -7,32 +7,10 @@ import { decode } from 'base64-arraybuffer';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import { Events } from '../../../lib/analytics';
|
||||
import { UploadStatus } from '../types';
|
||||
import { useAuthStore } from '../../../store';
|
||||
import {
|
||||
encryptMediaFile,
|
||||
getAdminPublicKey,
|
||||
getAdminKeyId,
|
||||
MESSAGE_VERSION_E2EE,
|
||||
isValidPublicKeyJwk,
|
||||
sanitizePublicKeyJwk,
|
||||
verifyKeyPair,
|
||||
} from '../../../lib/encryption';
|
||||
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
|
||||
import { useEncryptionKeys } from '../../../hooks';
|
||||
|
||||
export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState<UploadStatus>(null);
|
||||
const verifiedKeysVersionRef = useRef<number | null>(null);
|
||||
const partnerRefreshRef = useRef(0);
|
||||
const PARTNER_REFRESH_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
const refreshPartner = useAuthStore((s) => s.refreshPartner);
|
||||
const { publicKeyJwk, privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
||||
|
||||
// Check if E2EE is available for media
|
||||
const isE2EEAvailable =
|
||||
Platform.OS !== 'web' && hasKeys && !keysLoading && !!publicKeyJwk && !!privateKeyJwk;
|
||||
|
||||
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
|
||||
if (!userId) return;
|
||||
@@ -40,17 +18,9 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
setUploadStatus({ mediaType, status: mediaType === 'video' ? 'compressing' : 'uploading', thumbnailUri: uri });
|
||||
|
||||
try {
|
||||
// Block media uploads until encryption is ready (secure-only)
|
||||
if (!isE2EEAvailable || Platform.OS === 'web') {
|
||||
const readyError = new Error('Media security not ready. Please check your connection.');
|
||||
console.warn('[E2EE Media Upload] E2EE not available, blocking upload');
|
||||
throw readyError;
|
||||
}
|
||||
|
||||
let fileUri = uri;
|
||||
let fileBody;
|
||||
let ext = mediaType === 'video' ? 'mp4' : 'jpg';
|
||||
let encryptedPayload: EncryptedMediaPayload;
|
||||
|
||||
// Compress videos on native platforms before upload
|
||||
if (mediaType === 'video' && Platform.OS !== 'web') {
|
||||
@@ -73,108 +43,6 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 || !currentKeys.privateKeyJwk) {
|
||||
console.warn('[E2EE Media Upload] Keys not fully available in global store at encryption time');
|
||||
throw new Error('Media security not ready. Please check your connection.');
|
||||
}
|
||||
|
||||
const senderPublicKey = currentKeys.publicKeyJwk;
|
||||
const senderPrivateKey = currentKeys.privateKeyJwk;
|
||||
|
||||
// SAFETY CHECK: Verify the key pair works together before encrypting
|
||||
if (verifiedKeysVersionRef.current !== currentKeys.keysVersion) {
|
||||
console.log(
|
||||
`[E2EE Media Upload] Verifying sender key pair (version ${currentKeys.keysVersion})...`
|
||||
);
|
||||
const isValidKeyPair = await verifyKeyPair(
|
||||
senderPublicKey as RSAPublicKeyJWK,
|
||||
senderPrivateKey
|
||||
);
|
||||
|
||||
if (!isValidKeyPair) {
|
||||
console.error('[E2EE Media Upload] CRITICAL: Sender key pair verification failed!');
|
||||
throw new Error('Security check failed. Please restart the app.');
|
||||
}
|
||||
|
||||
verifiedKeysVersionRef.current = currentKeys.keysVersion;
|
||||
console.log('[E2EE Media Upload] Sender key pair verified successfully');
|
||||
}
|
||||
|
||||
console.log('[E2EE Media Upload] Using sender public key from global store, version:', currentKeys.keysVersion);
|
||||
|
||||
// Refresh partner's profile only when key is missing/invalid or TTL expires
|
||||
const resolvePartnerProfile = async () => {
|
||||
const cachedPartner = useAuthStore.getState().partner;
|
||||
const cachedKey = cachedPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
|
||||
const hasValidKey = cachedKey ? isValidPublicKeyJwk(cachedKey) : false;
|
||||
const now = Date.now();
|
||||
const shouldRefresh = !hasValidKey || (now - partnerRefreshRef.current) > PARTNER_REFRESH_TTL_MS;
|
||||
|
||||
if (!shouldRefresh) {
|
||||
return cachedPartner;
|
||||
}
|
||||
|
||||
console.log('[E2EE Media Upload] Refreshing partner profile...');
|
||||
try {
|
||||
const freshPartner = await refreshPartner();
|
||||
partnerRefreshRef.current = Date.now();
|
||||
return freshPartner ?? useAuthStore.getState().partner;
|
||||
} catch (refreshError) {
|
||||
console.warn('[E2EE Media Upload] Partner refresh failed, using cached key:', refreshError);
|
||||
partnerRefreshRef.current = Date.now();
|
||||
return cachedPartner;
|
||||
}
|
||||
};
|
||||
|
||||
const freshPartner = await resolvePartnerProfile();
|
||||
|
||||
let adminPublicKey: RSAPublicKeyJWK;
|
||||
let adminKeyId: string;
|
||||
|
||||
try {
|
||||
adminPublicKey = await getAdminPublicKey();
|
||||
adminKeyId = await getAdminKeyId();
|
||||
|
||||
if (!isValidPublicKeyJwk(adminPublicKey)) {
|
||||
throw new Error('Admin public key is invalid');
|
||||
}
|
||||
} catch (adminKeyError) {
|
||||
console.error('[E2EE Media Upload] Failed to fetch admin key:', adminKeyError);
|
||||
throw new Error('Connection issue. Please try again.');
|
||||
}
|
||||
|
||||
let partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
|
||||
|
||||
// Validate and sanitize partner's public key
|
||||
if (partnerPublicKey) {
|
||||
if (!isValidPublicKeyJwk(partnerPublicKey)) {
|
||||
console.warn('[E2EE Media Upload] Partner public key is invalid, treating as unavailable');
|
||||
partnerPublicKey = null;
|
||||
} else {
|
||||
// Sanitize to remove trailing dots from base64 fields
|
||||
partnerPublicKey = sanitizePublicKeyJwk(partnerPublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
const response = await fetch(fileUri);
|
||||
const blob = await response.blob();
|
||||
@@ -194,11 +62,10 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
} else {
|
||||
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
|
||||
fileBody = decode(base64);
|
||||
|
||||
}
|
||||
|
||||
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
|
||||
const contentType = 'application/octet-stream';
|
||||
const contentType = mediaType === 'video' ? 'video/mp4' : 'image/jpeg';
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("chat-media")
|
||||
@@ -209,16 +76,7 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
// 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
|
||||
// Store the message (plaintext v1)
|
||||
await supabase.from("messages").insert({
|
||||
match_id: matchId,
|
||||
user_id: userId,
|
||||
@@ -226,9 +84,7 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
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>,
|
||||
version: 1,
|
||||
});
|
||||
Events.mediaUploaded();
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import { useAuthStore, useSubscriptionStore } from '../../store';
|
||||
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
|
||||
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
|
||||
import { supabase } from '../../lib/supabase'; // Needed for debug reset
|
||||
import { clearKeys } from '../../lib/encryption';
|
||||
|
||||
// Hooks
|
||||
import { useProfileSettings, useCoupleManagement } from './hooks';
|
||||
@@ -294,29 +293,6 @@ 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.");
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useRouter } from 'expo-router';
|
||||
import { useAuthStore, useMatchStore, useMessageStore, usePacksStore } from '../../../store';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import { Events } from '../../../lib/analytics';
|
||||
import { clearKeys } from '../../../lib/encryption';
|
||||
|
||||
export function useCoupleManagement() {
|
||||
const router = useRouter();
|
||||
@@ -114,7 +113,6 @@ export function useCoupleManagement() {
|
||||
clearMatches();
|
||||
clearMessages();
|
||||
clearPacks();
|
||||
await clearKeys();
|
||||
|
||||
Events.accountDeleted();
|
||||
|
||||
|
||||
Reference in new issue
Block a user