Fix Android navigation and enforce secure-only messaging

- Remove forced redirect to pairing screen for users without a couple
- Users now go directly to dashboard after onboarding (matches iOS behavior)
- Add secureReady state to InputBar to prevent sending before E2EE is ready
- Pre-fetch admin key during key initialization to reduce chat load time
- Block media uploads until encryption is fully ready
- Add security status indicator in chat when encryption is initializing
This commit is contained in:
Sauci Dev committed 2026-01-07 21:42:19 +00:00
1 parent 4b8f4315e9
commit 953bd7c03d
7 files changed
+357 -177

No files matched your search

+55 -39
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { View, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
import { View, Text, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
import { Image } from "expo-image";
import { Video, ResizeMode } from "expo-av";
import { useLocalSearchParams, useRouter } from "expo-router";
@@ -19,7 +19,7 @@ import { useAuthStore, useMessageStore } from "../../store";
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useEncryptedSend, useMediaSaver } from "../../hooks";
import { Database } from "../../types/supabase";
import { GradientBackground } from "../../components/ui";
import { colors, gradients, spacing } from "../../theme";
import { colors, gradients, radius, spacing, typography } from "../../theme";
import { Events } from "../../lib/analytics";
type Message = Database["public"]["Tables"]["messages"]["Row"];
@@ -75,7 +75,8 @@ export const ChatScreen: React.FC = () => {
});
// E2EE encryption hook for sending messages
const { encryptMessage, isE2EEAvailable } = useEncryptedSend();
const { encryptMessage, isE2EEAvailable, isAdminKeyReady, isEncrypting } = useEncryptedSend();
const secureReady = isE2EEAvailable && isAdminKeyReady;
// Message actions hook for deletion and reporting
const { showDeleteOptions, deleteForSelf } = useMessageActions({
@@ -181,55 +182,41 @@ 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 {
// Try to encrypt the message if E2EE is available
// Encrypt the message (secure-only)
console.log(`[E2EE Send] isE2EEAvailable=${isE2EEAvailable}`);
const encryptedPayload = isE2EEAvailable ? await encryptMessage(content) : null;
console.log(`[E2EE Send] encryptedPayload=`, encryptedPayload ? {
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,
} : 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 });
// 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();
}
if (error) {
Alert.alert("Error", "Failed to send message");
setInputText(content);
} else {
// Fallback to plaintext (v1) if E2EE not available
const { error } = await supabase.from("messages").insert({
match_id: matchId,
user_id: user.id,
content: content,
});
if (error) {
Alert.alert("Error", "Failed to send message");
setInputText(content);
} else {
Events.messageSent();
}
Events.messageSent();
}
} catch (err) {
console.error("Error sending message:", err);
@@ -252,6 +239,7 @@ export const ChatScreen: React.FC = () => {
};
const handlePickMedia = async () => {
if (!secureReady) return;
const result = await pickMedia();
if (result) {
uploadMedia(result.uri, result.mediaType);
@@ -259,6 +247,7 @@ export const ChatScreen: React.FC = () => {
};
const handleTakePhoto = async () => {
if (!secureReady) return;
const result = await takePhoto();
if (result) {
uploadMedia(result.uri, 'image');
@@ -266,6 +255,7 @@ export const ChatScreen: React.FC = () => {
};
const handleRecordVideo = async () => {
if (!secureReady) return;
const result = await recordVideo();
if (result) {
uploadMedia(result.uri, 'video');
@@ -358,9 +348,17 @@ 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}
@@ -565,4 +563,22 @@ const styles = StyleSheet.create({
height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
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,6 +14,7 @@ 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;
@@ -30,6 +31,7 @@ const ACCENT = colors.premium.gold;
export function InputBar({
inputText,
uploading,
secureReady,
onChangeText,
onSend,
onPickMedia,
@@ -63,12 +65,17 @@ 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;
return (
<View style={styles.container}>
{/* Plus button to toggle media menu */}
<TouchableOpacity
onPress={toggleMenu}
disabled={uploading}
disabled={mediaDisabled}
style={styles.attachButton}
accessibilityRole="button"
accessibilityLabel="Toggle media menu"
@@ -89,6 +96,7 @@ export function InputBar({
onPress={() => handleMediaAction(onTakePhoto)}
style={styles.mediaMenuItem}
activeOpacity={0.7}
disabled={mediaDisabled}
accessibilityRole="button"
accessibilityLabel="Take photo"
testID="chat-input-take-photo"
@@ -102,6 +110,7 @@ export function InputBar({
onPress={() => handleMediaAction(onRecordVideo)}
style={styles.mediaMenuItem}
activeOpacity={0.7}
disabled={mediaDisabled}
accessibilityRole="button"
accessibilityLabel="Record video"
testID="chat-input-record-video"
@@ -115,6 +124,7 @@ export function InputBar({
onPress={() => handleMediaAction(onPickMedia)}
style={styles.mediaMenuItem}
activeOpacity={0.7}
disabled={mediaDisabled}
accessibilityRole="button"
accessibilityLabel="Pick media"
testID="chat-input-pick-media"
@@ -140,20 +150,21 @@ export function InputBar({
<TouchableOpacity
onPress={onSend}
disabled={!inputText.trim()}
disabled={sendDisabled}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel="Send message"
accessibilityLabel={waitingForSecurity ? "Send message - securing connection" : "Send message"}
accessibilityHint={waitingForSecurity ? "Please wait while we secure your connection" : undefined}
testID="chat-input-send"
>
<LinearGradient
colors={inputText.trim() ? gradients.primary as [string, string] : ['rgba(22, 33, 62, 0.6)', 'rgba(22, 33, 62, 0.6)']}
colors={sendDisabled ? ['rgba(22, 33, 62, 0.6)', 'rgba(22, 33, 62, 0.6)'] : gradients.primary as [string, string]}
style={styles.sendButton}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{/* Silk highlight */}
{inputText.trim() && (
{!sendDisabled && (
<LinearGradient
colors={['rgba(255, 255, 255, 0.2)', 'transparent']}
style={styles.sendButtonHighlight}
@@ -162,9 +173,9 @@ export function InputBar({
/>
)}
<Ionicons
name="send"
size={18}
color={inputText.trim() ? colors.text : colors.textTertiary}
name={waitingForSecurity ? "lock-closed" : "send"}
size={waitingForSecurity ? 16 : 18}
color={sendDisabled ? colors.textTertiary : colors.text}
/>
</LinearGradient>
</TouchableOpacity>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { Alert, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import { Video as VideoCompressor } from 'react-native-compressor';
@@ -15,6 +15,7 @@ import {
MESSAGE_VERSION_E2EE,
isValidPublicKeyJwk,
sanitizePublicKeyJwk,
verifyKeyPair,
} from '../../../lib/encryption';
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
import { useEncryptionKeys } from '../../../hooks';
@@ -22,12 +23,16 @@ 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, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
const { publicKeyJwk, privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
// Check if E2EE is available for media
const isE2EEAvailable = Platform.OS !== 'web' && hasKeys && !keysLoading && !!publicKeyJwk;
const isE2EEAvailable =
Platform.OS !== 'web' && hasKeys && !keysLoading && !!publicKeyJwk && !!privateKeyJwk;
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
if (!userId) return;
@@ -35,10 +40,17 @@ 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 | null = null;
let encryptedPayload: EncryptedMediaPayload;
// Compress videos on native platforms before upload
if (mediaType === 'video' && Platform.OS !== 'web') {
@@ -61,60 +73,108 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
}
}
// Try to encrypt the media if E2EE is available
if (isE2EEAvailable && Platform.OS !== 'web') {
// 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 {
// 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();
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 adminPublicKey = await getAdminPublicKey();
const adminKeyId = await getAdminKeyId();
let partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
const freshPartner = await resolvePartnerProfile();
// 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);
}
}
let adminPublicKey: RSAPublicKeyJWK;
let adminKeyId: string;
console.log('[E2EE Media Upload] Partner public key available:', !!partnerPublicKey);
try {
adminPublicKey = await getAdminPublicKey();
adminKeyId = await getAdminKeyId();
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 (!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();
@@ -135,21 +195,10 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
fileBody = decode(base64);
if (mediaType !== 'video' && !encryptedPayload) {
// Only use URI extension for non-videos and non-encrypted (videos are always mp4 after compression)
const uriExt = fileUri.split('.').pop();
if (uriExt && uriExt !== fileUri) {
ext = uriExt.toLowerCase();
}
}
}
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
const contentType = encryptedPayload
? 'application/octet-stream' // Encrypted files are binary
: mediaType === 'video'
? `video/${ext === 'mov' ? 'quicktime' : ext}`
: `image/${ext === 'jpg' ? 'jpeg' : ext}`;
const contentType = 'application/octet-stream';
const { error: uploadError } = await supabase.storage
.from("chat-media")
@@ -169,30 +218,18 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
}
}
// 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({
match_id: matchId,
user_id: userId,
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
media_path: fileName,
media_type: mediaType,
media_expired: false,
});
}
// Store the message with encryption metadata
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>,
});
Events.mediaUploaded();
} catch (error) {