mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 10:19:43 +00:00
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:
1 parent
4b8f4315e9
commit
953bd7c03d
7 files changed
+332
-152
No files matched your search
@@ -5,7 +5,7 @@ import { View, ActivityIndicator, StyleSheet } from "react-native";
|
|||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state while auth is being determined
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -24,12 +24,8 @@ export default function Index() {
|
|||||||
return <Redirect href="/(app)/onboarding" />;
|
return <Redirect href="/(app)/onboarding" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user has no couple, go to pairing
|
// Go to home/dashboard - users can pair from within the app
|
||||||
if (!user?.couple_id) {
|
// No forced redirect to pairing for users without a couple
|
||||||
return <Redirect href="/(app)/pairing" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise go to home
|
|
||||||
return <Redirect href="/(app)" />;
|
return <Redirect href="/(app)" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ describe('InputBar', () => {
|
|||||||
<InputBar
|
<InputBar
|
||||||
inputText=""
|
inputText=""
|
||||||
uploading={false}
|
uploading={false}
|
||||||
|
secureReady={true}
|
||||||
onChangeText={onChangeText}
|
onChangeText={onChangeText}
|
||||||
onSend={jest.fn()}
|
onSend={jest.fn()}
|
||||||
onPickMedia={jest.fn()}
|
onPickMedia={jest.fn()}
|
||||||
@@ -29,6 +30,7 @@ describe('InputBar', () => {
|
|||||||
<InputBar
|
<InputBar
|
||||||
inputText=""
|
inputText=""
|
||||||
uploading={false}
|
uploading={false}
|
||||||
|
secureReady={true}
|
||||||
onChangeText={jest.fn()}
|
onChangeText={jest.fn()}
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
onPickMedia={jest.fn()}
|
onPickMedia={jest.fn()}
|
||||||
@@ -48,6 +50,7 @@ describe('InputBar', () => {
|
|||||||
<InputBar
|
<InputBar
|
||||||
inputText=""
|
inputText=""
|
||||||
uploading={false}
|
uploading={false}
|
||||||
|
secureReady={true}
|
||||||
onChangeText={jest.fn()}
|
onChangeText={jest.fn()}
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
onPickMedia={jest.fn()}
|
onPickMedia={jest.fn()}
|
||||||
@@ -60,6 +63,7 @@ describe('InputBar', () => {
|
|||||||
<InputBar
|
<InputBar
|
||||||
inputText="hi"
|
inputText="hi"
|
||||||
uploading={false}
|
uploading={false}
|
||||||
|
secureReady={true}
|
||||||
onChangeText={jest.fn()}
|
onChangeText={jest.fn()}
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
onPickMedia={jest.fn()}
|
onPickMedia={jest.fn()}
|
||||||
@@ -72,6 +76,26 @@ describe('InputBar', () => {
|
|||||||
expect(onSend).toHaveBeenCalledTimes(1);
|
expect(onSend).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disables send when secure messaging is not ready', () => {
|
||||||
|
const onSend = jest.fn();
|
||||||
|
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<InputBar
|
||||||
|
inputText="hello"
|
||||||
|
uploading={false}
|
||||||
|
secureReady={false}
|
||||||
|
onChangeText={jest.fn()}
|
||||||
|
onSend={onSend}
|
||||||
|
onPickMedia={jest.fn()}
|
||||||
|
onTakePhoto={jest.fn()}
|
||||||
|
onRecordVideo={jest.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('chat-input-send'));
|
||||||
|
expect(onSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('calls media actions from menu buttons', () => {
|
it('calls media actions from menu buttons', () => {
|
||||||
const onPickMedia = jest.fn();
|
const onPickMedia = jest.fn();
|
||||||
const onTakePhoto = jest.fn();
|
const onTakePhoto = jest.fn();
|
||||||
@@ -81,6 +105,7 @@ describe('InputBar', () => {
|
|||||||
<InputBar
|
<InputBar
|
||||||
inputText="hello"
|
inputText="hello"
|
||||||
uploading={false}
|
uploading={false}
|
||||||
|
secureReady={true}
|
||||||
onChangeText={jest.fn()}
|
onChangeText={jest.fn()}
|
||||||
onSend={jest.fn()}
|
onSend={jest.fn()}
|
||||||
onPickMedia={onPickMedia}
|
onPickMedia={onPickMedia}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Platform } from 'react-native';
|
|||||||
import { useEncryptionKeys } from '../hooks';
|
import { useEncryptionKeys } from '../hooks';
|
||||||
import { useAuthStore } from '../store';
|
import { useAuthStore } from '../store';
|
||||||
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
||||||
import { triggerKeyRotation } from '../lib/encryption';
|
import { triggerKeyRotation, getAdminPublicKey } from '../lib/encryption';
|
||||||
import { LoadingOverlay } from './ui/LoadingOverlay';
|
import { LoadingOverlay } from './ui/LoadingOverlay';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
|
|
||||||
@@ -97,6 +97,16 @@ export function EncryptionKeyInitializer(): JSX.Element | null {
|
|||||||
|
|
||||||
console.log('[E2EE Init] Encryption keys initialized and uploaded');
|
console.log('[E2EE Init] Encryption keys initialized and uploaded');
|
||||||
|
|
||||||
|
// Pre-fetch admin key to warm the cache before user reaches chat
|
||||||
|
// This reduces the delay before secureReady becomes true
|
||||||
|
try {
|
||||||
|
await getAdminPublicKey();
|
||||||
|
console.log('[E2EE Init] Admin key pre-fetched successfully');
|
||||||
|
} catch (adminKeyError) {
|
||||||
|
// Non-fatal: admin key will be fetched again when needed
|
||||||
|
console.warn('[E2EE Init] Failed to pre-fetch admin key:', adminKeyError);
|
||||||
|
}
|
||||||
|
|
||||||
// Track if keys were regenerated (for rotation decision)
|
// Track if keys were regenerated (for rotation decision)
|
||||||
if (result.wasRegenerated) {
|
if (result.wasRegenerated) {
|
||||||
console.log('[E2EE Init] Keys were regenerated this session');
|
console.log('[E2EE Init] Keys were regenerated this session');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
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 { Image } from "expo-image";
|
||||||
import { Video, ResizeMode } from "expo-av";
|
import { Video, ResizeMode } from "expo-av";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
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 { 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, radius, spacing, typography } from "../../theme";
|
||||||
import { Events } from "../../lib/analytics";
|
import { Events } from "../../lib/analytics";
|
||||||
|
|
||||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||||
@@ -75,7 +75,8 @@ export const ChatScreen: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// E2EE encryption hook for sending messages
|
// 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
|
// Message actions hook for deletion and reporting
|
||||||
const { showDeleteOptions, deleteForSelf } = useMessageActions({
|
const { showDeleteOptions, deleteForSelf } = useMessageActions({
|
||||||
@@ -181,55 +182,41 @@ export const ChatScreen: React.FC = () => {
|
|||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!inputText.trim()) return;
|
if (!inputText.trim()) return;
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
if (!secureReady) return;
|
||||||
|
if (isEncrypting) return;
|
||||||
|
|
||||||
const content = inputText.trim();
|
const content = inputText.trim();
|
||||||
setInputText("");
|
setInputText("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to encrypt the message if E2EE is available
|
// Encrypt the message (secure-only)
|
||||||
console.log(`[E2EE Send] isE2EEAvailable=${isE2EEAvailable}`);
|
console.log(`[E2EE Send] isE2EEAvailable=${isE2EEAvailable}`);
|
||||||
const encryptedPayload = isE2EEAvailable ? await encryptMessage(content) : null;
|
const encryptedPayload = await encryptMessage(content);
|
||||||
console.log(`[E2EE Send] encryptedPayload=`, encryptedPayload ? {
|
console.log(`[E2EE Send] encryptedPayload=`, {
|
||||||
version: encryptedPayload.version,
|
version: encryptedPayload.version,
|
||||||
encrypted_content_length: encryptedPayload.encrypted_content.length,
|
encrypted_content_length: encryptedPayload.encrypted_content.length,
|
||||||
encryption_iv_length: encryptedPayload.encryption_iv.length,
|
encryption_iv_length: encryptedPayload.encryption_iv.length,
|
||||||
keys_metadata: encryptedPayload.keys_metadata,
|
keys_metadata: encryptedPayload.keys_metadata,
|
||||||
} : null);
|
});
|
||||||
|
|
||||||
if (encryptedPayload) {
|
// Send encrypted message (v2)
|
||||||
// Send encrypted message (v2)
|
const { error, data } = await supabase.from("messages").insert({
|
||||||
const { error, data } = await supabase.from("messages").insert({
|
match_id: matchId,
|
||||||
match_id: matchId,
|
user_id: user.id,
|
||||||
user_id: user.id,
|
content: null,
|
||||||
content: null,
|
version: encryptedPayload.version,
|
||||||
version: encryptedPayload.version,
|
encrypted_content: encryptedPayload.encrypted_content,
|
||||||
encrypted_content: encryptedPayload.encrypted_content,
|
encryption_iv: encryptedPayload.encryption_iv,
|
||||||
encryption_iv: encryptedPayload.encryption_iv,
|
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
|
||||||
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
|
}).select();
|
||||||
}).select();
|
|
||||||
|
|
||||||
console.log(`[E2EE Send] Insert result:`, { error, data });
|
console.log(`[E2EE Send] Insert result:`, { error, data });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
Alert.alert("Error", "Failed to send message");
|
Alert.alert("Error", "Failed to send message");
|
||||||
setInputText(content);
|
setInputText(content);
|
||||||
} else {
|
|
||||||
Events.messageSent();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to plaintext (v1) if E2EE not available
|
Events.messageSent();
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error sending message:", err);
|
console.error("Error sending message:", err);
|
||||||
@@ -252,6 +239,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePickMedia = async () => {
|
const handlePickMedia = async () => {
|
||||||
|
if (!secureReady) return;
|
||||||
const result = await pickMedia();
|
const result = await pickMedia();
|
||||||
if (result) {
|
if (result) {
|
||||||
uploadMedia(result.uri, result.mediaType);
|
uploadMedia(result.uri, result.mediaType);
|
||||||
@@ -259,6 +247,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTakePhoto = async () => {
|
const handleTakePhoto = async () => {
|
||||||
|
if (!secureReady) return;
|
||||||
const result = await takePhoto();
|
const result = await takePhoto();
|
||||||
if (result) {
|
if (result) {
|
||||||
uploadMedia(result.uri, 'image');
|
uploadMedia(result.uri, 'image');
|
||||||
@@ -266,6 +255,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRecordVideo = async () => {
|
const handleRecordVideo = async () => {
|
||||||
|
if (!secureReady) return;
|
||||||
const result = await recordVideo();
|
const result = await recordVideo();
|
||||||
if (result) {
|
if (result) {
|
||||||
uploadMedia(result.uri, 'video');
|
uploadMedia(result.uri, 'video');
|
||||||
@@ -358,9 +348,17 @@ export const ChatScreen: React.FC = () => {
|
|||||||
{/* Top border glow */}
|
{/* Top border glow */}
|
||||||
<View style={styles.inputWrapperBorder} />
|
<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
|
<InputBar
|
||||||
inputText={inputText}
|
inputText={inputText}
|
||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
|
secureReady={secureReady}
|
||||||
onChangeText={handleTyping}
|
onChangeText={handleTyping}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onPickMedia={handlePickMedia}
|
onPickMedia={handlePickMedia}
|
||||||
@@ -565,4 +563,22 @@ const styles = StyleSheet.create({
|
|||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: `${ACCENT_RGBA}0.15)`,
|
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 {
|
export interface InputBarProps {
|
||||||
inputText: string;
|
inputText: string;
|
||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
|
secureReady: boolean;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
onPickMedia: () => void;
|
onPickMedia: () => void;
|
||||||
@@ -30,6 +31,7 @@ const ACCENT = colors.premium.gold;
|
|||||||
export function InputBar({
|
export function InputBar({
|
||||||
inputText,
|
inputText,
|
||||||
uploading,
|
uploading,
|
||||||
|
secureReady,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
onSend,
|
onSend,
|
||||||
onPickMedia,
|
onPickMedia,
|
||||||
@@ -63,12 +65,17 @@ export function InputBar({
|
|||||||
transform: [{ rotate: `${buttonRotation.value}deg` }],
|
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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Plus button to toggle media menu */}
|
{/* Plus button to toggle media menu */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={toggleMenu}
|
onPress={toggleMenu}
|
||||||
disabled={uploading}
|
disabled={mediaDisabled}
|
||||||
style={styles.attachButton}
|
style={styles.attachButton}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Toggle media menu"
|
accessibilityLabel="Toggle media menu"
|
||||||
@@ -89,6 +96,7 @@ export function InputBar({
|
|||||||
onPress={() => handleMediaAction(onTakePhoto)}
|
onPress={() => handleMediaAction(onTakePhoto)}
|
||||||
style={styles.mediaMenuItem}
|
style={styles.mediaMenuItem}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
disabled={mediaDisabled}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Take photo"
|
accessibilityLabel="Take photo"
|
||||||
testID="chat-input-take-photo"
|
testID="chat-input-take-photo"
|
||||||
@@ -102,6 +110,7 @@ export function InputBar({
|
|||||||
onPress={() => handleMediaAction(onRecordVideo)}
|
onPress={() => handleMediaAction(onRecordVideo)}
|
||||||
style={styles.mediaMenuItem}
|
style={styles.mediaMenuItem}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
disabled={mediaDisabled}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Record video"
|
accessibilityLabel="Record video"
|
||||||
testID="chat-input-record-video"
|
testID="chat-input-record-video"
|
||||||
@@ -115,6 +124,7 @@ export function InputBar({
|
|||||||
onPress={() => handleMediaAction(onPickMedia)}
|
onPress={() => handleMediaAction(onPickMedia)}
|
||||||
style={styles.mediaMenuItem}
|
style={styles.mediaMenuItem}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
disabled={mediaDisabled}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Pick media"
|
accessibilityLabel="Pick media"
|
||||||
testID="chat-input-pick-media"
|
testID="chat-input-pick-media"
|
||||||
@@ -140,20 +150,21 @@ export function InputBar({
|
|||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onSend}
|
onPress={onSend}
|
||||||
disabled={!inputText.trim()}
|
disabled={sendDisabled}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
accessibilityRole="button"
|
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"
|
testID="chat-input-send"
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<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}
|
style={styles.sendButton}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
>
|
>
|
||||||
{/* Silk highlight */}
|
{/* Silk highlight */}
|
||||||
{inputText.trim() && (
|
{!sendDisabled && (
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['rgba(255, 255, 255, 0.2)', 'transparent']}
|
colors={['rgba(255, 255, 255, 0.2)', 'transparent']}
|
||||||
style={styles.sendButtonHighlight}
|
style={styles.sendButtonHighlight}
|
||||||
@@ -162,9 +173,9 @@ export function InputBar({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="send"
|
name={waitingForSecurity ? "lock-closed" : "send"}
|
||||||
size={18}
|
size={waitingForSecurity ? 16 : 18}
|
||||||
color={inputText.trim() ? colors.text : colors.textTertiary}
|
color={sendDisabled ? colors.textTertiary : colors.text}
|
||||||
/>
|
/>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Alert, Platform } from 'react-native';
|
import { Alert, Platform } from 'react-native';
|
||||||
import * as FileSystem from 'expo-file-system';
|
import * as FileSystem from 'expo-file-system';
|
||||||
import { Video as VideoCompressor } from 'react-native-compressor';
|
import { Video as VideoCompressor } from 'react-native-compressor';
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
MESSAGE_VERSION_E2EE,
|
MESSAGE_VERSION_E2EE,
|
||||||
isValidPublicKeyJwk,
|
isValidPublicKeyJwk,
|
||||||
sanitizePublicKeyJwk,
|
sanitizePublicKeyJwk,
|
||||||
|
verifyKeyPair,
|
||||||
} from '../../../lib/encryption';
|
} from '../../../lib/encryption';
|
||||||
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
|
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
|
||||||
import { useEncryptionKeys } from '../../../hooks';
|
import { useEncryptionKeys } from '../../../hooks';
|
||||||
@@ -22,12 +23,16 @@ 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 verifiedKeysVersionRef = useRef<number | null>(null);
|
||||||
|
const partnerRefreshRef = useRef(0);
|
||||||
|
const PARTNER_REFRESH_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
const refreshPartner = useAuthStore((s) => s.refreshPartner);
|
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
|
// 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') => {
|
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
@@ -35,10 +40,17 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
|||||||
setUploadStatus({ mediaType, status: mediaType === 'video' ? 'compressing' : 'uploading', thumbnailUri: uri });
|
setUploadStatus({ mediaType, status: mediaType === 'video' ? 'compressing' : 'uploading', thumbnailUri: uri });
|
||||||
|
|
||||||
try {
|
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 fileUri = uri;
|
||||||
let fileBody;
|
let fileBody;
|
||||||
let ext = mediaType === 'video' ? 'mp4' : 'jpg';
|
let ext = mediaType === 'video' ? 'mp4' : 'jpg';
|
||||||
let encryptedPayload: EncryptedMediaPayload | null = null;
|
let encryptedPayload: EncryptedMediaPayload;
|
||||||
|
|
||||||
// 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') {
|
||||||
@@ -61,60 +73,108 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to encrypt the media if E2EE is available
|
// CRITICAL: Read keys directly from global store at encryption time
|
||||||
if (isE2EEAvailable && Platform.OS !== 'web') {
|
// 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 {
|
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 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 freshPartner = await resolvePartnerProfile();
|
||||||
const adminKeyId = await getAdminKeyId();
|
|
||||||
let partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
|
|
||||||
|
|
||||||
// Validate and sanitize partner's public key
|
let adminPublicKey: RSAPublicKeyJWK;
|
||||||
if (partnerPublicKey) {
|
let adminKeyId: string;
|
||||||
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);
|
try {
|
||||||
|
adminPublicKey = await getAdminPublicKey();
|
||||||
|
adminKeyId = await getAdminKeyId();
|
||||||
|
|
||||||
encryptedPayload = await encryptMediaFile(
|
if (!isValidPublicKeyJwk(adminPublicKey)) {
|
||||||
fileUri,
|
throw new Error('Admin public key is invalid');
|
||||||
senderPublicKey as RSAPublicKeyJWK,
|
}
|
||||||
partnerPublicKey ?? null,
|
} catch (adminKeyError) {
|
||||||
adminPublicKey,
|
console.error('[E2EE Media Upload] Failed to fetch admin key:', adminKeyError);
|
||||||
adminKeyId
|
throw new Error('Connection issue. Please try again.');
|
||||||
);
|
}
|
||||||
|
|
||||||
// Use the encrypted file for upload
|
let partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
|
||||||
fileUri = encryptedPayload.encryptedFileUri;
|
|
||||||
ext = 'enc'; // Encrypted files use .enc extension
|
// Validate and sanitize partner's public key
|
||||||
console.log('Media encrypted successfully');
|
if (partnerPublicKey) {
|
||||||
} catch (encryptError) {
|
if (!isValidPublicKeyJwk(partnerPublicKey)) {
|
||||||
console.warn('Media encryption failed, uploading unencrypted:', encryptError);
|
console.warn('[E2EE Media Upload] Partner public key is invalid, treating as unavailable');
|
||||||
encryptedPayload = null;
|
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') {
|
if (Platform.OS === 'web') {
|
||||||
const response = await fetch(fileUri);
|
const response = await fetch(fileUri);
|
||||||
const blob = await response.blob();
|
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 });
|
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
|
||||||
fileBody = decode(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 fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
|
||||||
const contentType = encryptedPayload
|
const contentType = 'application/octet-stream';
|
||||||
? 'application/octet-stream' // Encrypted files are binary
|
|
||||||
: mediaType === 'video'
|
|
||||||
? `video/${ext === 'mov' ? 'quicktime' : ext}`
|
|
||||||
: `image/${ext === 'jpg' ? 'jpeg' : ext}`;
|
|
||||||
|
|
||||||
const { error: uploadError } = await supabase.storage
|
const { error: uploadError } = await supabase.storage
|
||||||
.from("chat-media")
|
.from("chat-media")
|
||||||
@@ -169,30 +218,18 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the message with encryption metadata if encrypted
|
// Store the message with encryption metadata
|
||||||
if (encryptedPayload) {
|
await supabase.from("messages").insert({
|
||||||
await supabase.from("messages").insert({
|
match_id: matchId,
|
||||||
match_id: matchId,
|
user_id: userId,
|
||||||
user_id: userId,
|
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
|
||||||
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
|
media_path: fileName,
|
||||||
media_path: fileName,
|
media_type: mediaType,
|
||||||
media_type: mediaType,
|
media_expired: false,
|
||||||
media_expired: false,
|
version: MESSAGE_VERSION_E2EE,
|
||||||
version: MESSAGE_VERSION_E2EE,
|
encryption_iv: encryptedPayload.encryption_iv,
|
||||||
encryption_iv: encryptedPayload.encryption_iv,
|
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Events.mediaUploaded();
|
Events.mediaUploaded();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* consistency with all other encryption operations.
|
* consistency with all other encryption operations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState, useRef } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import {
|
import {
|
||||||
encryptTextMessage,
|
encryptTextMessage,
|
||||||
@@ -26,9 +26,11 @@ import { captureError } from '../lib/sentry';
|
|||||||
|
|
||||||
interface UseEncryptedSendReturn {
|
interface UseEncryptedSendReturn {
|
||||||
/** Encrypt a text message for sending */
|
/** Encrypt a text message for sending */
|
||||||
encryptMessage: (plaintext: string) => Promise<EncryptedMessagePayload | null>;
|
encryptMessage: (plaintext: string) => Promise<EncryptedMessagePayload>;
|
||||||
/** Whether E2EE is available (keys loaded, not on web) */
|
/** Whether E2EE is available (keys loaded, not on web) */
|
||||||
isE2EEAvailable: boolean;
|
isE2EEAvailable: boolean;
|
||||||
|
/** Whether the admin key is available and valid */
|
||||||
|
isAdminKeyReady: boolean;
|
||||||
/** Whether encryption is currently in progress */
|
/** Whether encryption is currently in progress */
|
||||||
isEncrypting: boolean;
|
isEncrypting: boolean;
|
||||||
/** Last encryption error, if any */
|
/** Last encryption error, if any */
|
||||||
@@ -38,17 +40,21 @@ interface UseEncryptedSendReturn {
|
|||||||
/**
|
/**
|
||||||
* Hook for encrypting messages before sending
|
* Hook for encrypting messages before sending
|
||||||
*
|
*
|
||||||
* Returns null if E2EE is not available (web platform, no keys).
|
* Throws if E2EE is not available or keys are not ready.
|
||||||
* In that case, the caller should send plaintext (v1) messages.
|
|
||||||
*/
|
*/
|
||||||
export function useEncryptedSend(): UseEncryptedSendReturn {
|
export function useEncryptedSend(): UseEncryptedSendReturn {
|
||||||
const [isEncrypting, setIsEncrypting] = useState(false);
|
const [isEncrypting, setIsEncrypting] = useState(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const [isAdminKeyReady, setIsAdminKeyReady] = useState(false);
|
||||||
|
|
||||||
// Track the version of keys we have verified to handle key rotation/regeneration
|
// Track the version of keys we have verified to handle key rotation/regeneration
|
||||||
const verifiedKeysVersionRef = useRef<number | null>(null);
|
const verifiedKeysVersionRef = useRef<number | null>(null);
|
||||||
|
const partnerRefreshRef = useRef(0);
|
||||||
|
const adminKeyRetryRef = useRef(0);
|
||||||
|
const PARTNER_REFRESH_TTL_MS = 2 * 60 * 1000;
|
||||||
|
const ADMIN_KEY_RETRY_DELAYS = [500, 1000, 2000, 4000]; // Exponential backoff delays in ms
|
||||||
|
|
||||||
const { publicKeyJwk, hasKeys, isLoading } = useEncryptionKeys();
|
const { publicKeyJwk, privateKeyJwk, hasKeys, isLoading } = useEncryptionKeys();
|
||||||
const refreshPartner = useAuthStore((s) => s.refreshPartner);
|
const refreshPartner = useAuthStore((s) => s.refreshPartner);
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
@@ -56,14 +62,71 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
|
|||||||
// - Not on web platform
|
// - Not on web platform
|
||||||
// - Keys are loaded and available
|
// - Keys are loaded and available
|
||||||
const isE2EEAvailable =
|
const isE2EEAvailable =
|
||||||
Platform.OS !== 'web' && hasKeys && !isLoading && !!publicKeyJwk;
|
Platform.OS !== 'web' && hasKeys && !isLoading && !!publicKeyJwk && !!privateKeyJwk;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
let retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const checkAdminKey = async (retryCount = 0) => {
|
||||||
|
if (!isE2EEAvailable) {
|
||||||
|
if (isMounted) {
|
||||||
|
setIsAdminKeyReady(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adminPublicKey = await getAdminPublicKey();
|
||||||
|
const adminKeyId = await getAdminKeyId();
|
||||||
|
|
||||||
|
if (!adminKeyId || !isValidPublicKeyJwk(adminPublicKey)) {
|
||||||
|
throw new Error('Admin public key is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setIsAdminKeyReady(true);
|
||||||
|
adminKeyRetryRef.current = 0; // Reset retry count on success
|
||||||
|
}
|
||||||
|
} catch (adminKeyError) {
|
||||||
|
console.warn(`[E2EE Send] Admin key not ready (attempt ${retryCount + 1}):`, adminKeyError);
|
||||||
|
if (isMounted) {
|
||||||
|
setIsAdminKeyReady(false);
|
||||||
|
|
||||||
|
// Retry with exponential backoff if we haven't exhausted retries
|
||||||
|
if (retryCount < ADMIN_KEY_RETRY_DELAYS.length) {
|
||||||
|
const delay = ADMIN_KEY_RETRY_DELAYS[retryCount];
|
||||||
|
console.log(`[E2EE Send] Retrying admin key fetch in ${delay}ms...`);
|
||||||
|
retryTimeoutId = setTimeout(() => {
|
||||||
|
if (isMounted) {
|
||||||
|
checkAdminKey(retryCount + 1);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
console.warn('[E2EE Send] Admin key fetch failed after all retries');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAdminKey();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
if (retryTimeoutId) {
|
||||||
|
clearTimeout(retryTimeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isE2EEAvailable]);
|
||||||
|
|
||||||
const encryptMessage = useCallback(
|
const encryptMessage = useCallback(
|
||||||
async (plaintext: string): Promise<EncryptedMessagePayload | null> => {
|
async (plaintext: string): Promise<EncryptedMessagePayload> => {
|
||||||
// Return null if E2EE not available - caller will send plaintext
|
// Block sending until E2EE is ready
|
||||||
if (!isE2EEAvailable) {
|
if (!isE2EEAvailable) {
|
||||||
console.log('[E2EE Send] E2EE not available, will send plaintext');
|
const readyError = new Error('Message security not ready. Please check your connection.');
|
||||||
return null;
|
console.warn('[E2EE Send] E2EE not available');
|
||||||
|
setError(readyError);
|
||||||
|
throw readyError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL: Read keys directly from the global store at encryption time
|
// CRITICAL: Read keys directly from the global store at encryption time
|
||||||
@@ -114,10 +177,31 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Always refresh partner's profile to get their latest public key
|
// Refresh partner's profile only when key is missing/invalid or TTL expires
|
||||||
// This ensures we don't miss their key if they just generated it
|
const resolvePartnerProfile = async () => {
|
||||||
console.log('[E2EE Send] Refreshing partner profile to get latest public key...');
|
const cachedPartner = useAuthStore.getState().partner;
|
||||||
const freshPartner = await refreshPartner();
|
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 Send] Refreshing partner profile to get latest public key...');
|
||||||
|
try {
|
||||||
|
const freshPartner = await refreshPartner();
|
||||||
|
partnerRefreshRef.current = Date.now();
|
||||||
|
return freshPartner ?? useAuthStore.getState().partner;
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.warn('[E2EE Send] Partner refresh failed, using cached key:', refreshError);
|
||||||
|
partnerRefreshRef.current = Date.now();
|
||||||
|
return cachedPartner;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const freshPartner = await resolvePartnerProfile();
|
||||||
|
|
||||||
// Get admin key for moderation access
|
// Get admin key for moderation access
|
||||||
// SAFETY: Validate the admin key before using it
|
// SAFETY: Validate the admin key before using it
|
||||||
@@ -258,6 +342,7 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
|
|||||||
return {
|
return {
|
||||||
encryptMessage,
|
encryptMessage,
|
||||||
isE2EEAvailable,
|
isE2EEAvailable,
|
||||||
|
isAdminKeyReady,
|
||||||
isEncrypting,
|
isEncrypting,
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user