diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx
index 0b1c1e0..3f7312b 100755
--- a/apps/mobile/app/index.tsx
+++ b/apps/mobile/app/index.tsx
@@ -2,36 +2,32 @@ import { Redirect } from "expo-router";
import { useAuthStore } from "../src/store";
import { View, ActivityIndicator, StyleSheet } from "react-native";
-export default function Index() {
- const { isAuthenticated, isLoading, user } = useAuthStore();
-
- // Show loading state
- if (isLoading) {
- return (
-
-
-
- );
- }
-
- // Redirect based on auth state
- if (!isAuthenticated) {
- return ;
- }
-
- // If user hasn't completed onboarding, go to onboarding first
- if (!user?.onboarding_completed) {
- return ;
- }
-
- // If user has no couple, go to pairing
- if (!user?.couple_id) {
- return ;
- }
-
- // Otherwise go to home
- return ;
-}
+export default function Index() {
+ const { isAuthenticated, isLoading, user } = useAuthStore();
+
+ // Show loading state while auth is being determined
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ // Redirect based on auth state
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ // If user hasn't completed onboarding, go to onboarding first
+ if (!user?.onboarding_completed) {
+ return ;
+ }
+
+ // Go to home/dashboard - users can pair from within the app
+ // No forced redirect to pairing for users without a couple
+ return ;
+}
const styles = StyleSheet.create({
container: {
diff --git a/apps/mobile/src/__tests__/components/InputBar.test.tsx b/apps/mobile/src/__tests__/components/InputBar.test.tsx
index 5f8e683..075821f 100644
--- a/apps/mobile/src/__tests__/components/InputBar.test.tsx
+++ b/apps/mobile/src/__tests__/components/InputBar.test.tsx
@@ -10,6 +10,7 @@ describe('InputBar', () => {
{
{
{
{
expect(onSend).toHaveBeenCalledTimes(1);
});
+ it('disables send when secure messaging is not ready', () => {
+ const onSend = jest.fn();
+
+ const { getByTestId } = render(
+
+ );
+
+ fireEvent.press(getByTestId('chat-input-send'));
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
it('calls media actions from menu buttons', () => {
const onPickMedia = jest.fn();
const onTakePhoto = jest.fn();
@@ -81,6 +105,7 @@ describe('InputBar', () => {
{
});
// 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,
- }).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,
+ }).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 */}
+ {!secureReady && (
+
+
+ Finishing security setup...
+
+ )}
+
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 (
{/* Plus button to toggle media menu */}
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({
{/* Silk highlight */}
- {inputText.trim() && (
+ {!sendDisabled && (
)}
diff --git a/apps/mobile/src/features/chat/hooks/useMediaUpload.ts b/apps/mobile/src/features/chat/hooks/useMediaUpload.ts
index 10ecd14..0ec1073 100644
--- a/apps/mobile/src/features/chat/hooks/useMediaUpload.ts
+++ b/apps/mobile/src/features/chat/hooks/useMediaUpload.ts
@@ -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(null);
+ const verifiedKeysVersionRef = useRef(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,
- });
- } 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,
+ });
Events.mediaUploaded();
} catch (error) {
diff --git a/apps/mobile/src/hooks/useEncryptedSend.ts b/apps/mobile/src/hooks/useEncryptedSend.ts
index 637d2a1..11f0cce 100644
--- a/apps/mobile/src/hooks/useEncryptedSend.ts
+++ b/apps/mobile/src/hooks/useEncryptedSend.ts
@@ -8,7 +8,7 @@
* 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 {
encryptTextMessage,
@@ -26,9 +26,11 @@ import { captureError } from '../lib/sentry';
interface UseEncryptedSendReturn {
/** Encrypt a text message for sending */
- encryptMessage: (plaintext: string) => Promise;
+ encryptMessage: (plaintext: string) => Promise;
/** Whether E2EE is available (keys loaded, not on web) */
isE2EEAvailable: boolean;
+ /** Whether the admin key is available and valid */
+ isAdminKeyReady: boolean;
/** Whether encryption is currently in progress */
isEncrypting: boolean;
/** Last encryption error, if any */
@@ -38,17 +40,21 @@ interface UseEncryptedSendReturn {
/**
* Hook for encrypting messages before sending
*
- * Returns null if E2EE is not available (web platform, no keys).
- * In that case, the caller should send plaintext (v1) messages.
+ * Throws if E2EE is not available or keys are not ready.
*/
export function useEncryptedSend(): UseEncryptedSendReturn {
const [isEncrypting, setIsEncrypting] = useState(false);
const [error, setError] = useState(null);
+ const [isAdminKeyReady, setIsAdminKeyReady] = useState(false);
// Track the version of keys we have verified to handle key rotation/regeneration
const verifiedKeysVersionRef = useRef(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 user = useAuthStore((s) => s.user);
@@ -56,14 +62,71 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
// - Not on web platform
// - Keys are loaded and available
const isE2EEAvailable =
- Platform.OS !== 'web' && hasKeys && !isLoading && !!publicKeyJwk;
+ Platform.OS !== 'web' && hasKeys && !isLoading && !!publicKeyJwk && !!privateKeyJwk;
+
+ useEffect(() => {
+ let isMounted = true;
+ let retryTimeoutId: ReturnType | 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(
- async (plaintext: string): Promise => {
- // Return null if E2EE not available - caller will send plaintext
+ async (plaintext: string): Promise => {
+ // Block sending until E2EE is ready
if (!isE2EEAvailable) {
- console.log('[E2EE Send] E2EE not available, will send plaintext');
- return null;
+ const readyError = new Error('Message security not ready. Please check your connection.');
+ console.warn('[E2EE Send] E2EE not available');
+ setError(readyError);
+ throw readyError;
}
// CRITICAL: Read keys directly from the global store at encryption time
@@ -114,10 +177,31 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
setError(null);
try {
- // Always refresh partner's profile to get their latest public key
- // This ensures we don't miss their key if they just generated it
- console.log('[E2EE Send] Refreshing partner profile to get latest public key...');
- const freshPartner = await refreshPartner();
+ // 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 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
// SAFETY: Validate the admin key before using it
@@ -258,6 +342,7 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
return {
encryptMessage,
isE2EEAvailable,
+ isAdminKeyReady,
isEncrypting,
error,
};