From 953bd7c03d21b8d16bb98315ba2831f59bb2e626 Mon Sep 17 00:00:00 2001 From: Sauci Dev Date: Wed, 7 Jan 2026 21:42:19 +0000 Subject: [PATCH] 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 --- apps/mobile/app/index.tsx | 56 +++-- .../__tests__/components/InputBar.test.tsx | 25 +++ .../components/EncryptionKeyInitializer.tsx | 12 +- apps/mobile/src/features/chat/ChatScreen.tsx | 94 ++++---- .../src/features/chat/components/InputBar.tsx | 27 ++- .../src/features/chat/hooks/useMediaUpload.ts | 207 +++++++++++------- apps/mobile/src/hooks/useEncryptedSend.ts | 113 ++++++++-- 7 files changed, 357 insertions(+), 177 deletions(-) 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, };