mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18: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
+357
-177
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { Video as VideoCompressor } from 'react-native-compressor';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
MESSAGE_VERSION_E2EE,
|
||||
isValidPublicKeyJwk,
|
||||
sanitizePublicKeyJwk,
|
||||
verifyKeyPair,
|
||||
} from '../../../lib/encryption';
|
||||
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
|
||||
import { useEncryptionKeys } from '../../../hooks';
|
||||
@@ -22,12 +23,16 @@ import { useEncryptionKeys } from '../../../hooks';
|
||||
export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState<UploadStatus>(null);
|
||||
const verifiedKeysVersionRef = useRef<number | null>(null);
|
||||
const partnerRefreshRef = useRef(0);
|
||||
const PARTNER_REFRESH_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
const refreshPartner = useAuthStore((s) => s.refreshPartner);
|
||||
const { publicKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
||||
const { publicKeyJwk, privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
||||
|
||||
// Check if E2EE is available for media
|
||||
const isE2EEAvailable = Platform.OS !== 'web' && hasKeys && !keysLoading && !!publicKeyJwk;
|
||||
const isE2EEAvailable =
|
||||
Platform.OS !== 'web' && hasKeys && !keysLoading && !!publicKeyJwk && !!privateKeyJwk;
|
||||
|
||||
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
|
||||
if (!userId) return;
|
||||
@@ -35,10 +40,17 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
setUploadStatus({ mediaType, status: mediaType === 'video' ? 'compressing' : 'uploading', thumbnailUri: uri });
|
||||
|
||||
try {
|
||||
// Block media uploads until encryption is ready (secure-only)
|
||||
if (!isE2EEAvailable || Platform.OS === 'web') {
|
||||
const readyError = new Error('Media security not ready. Please check your connection.');
|
||||
console.warn('[E2EE Media Upload] E2EE not available, blocking upload');
|
||||
throw readyError;
|
||||
}
|
||||
|
||||
let fileUri = uri;
|
||||
let fileBody;
|
||||
let ext = mediaType === 'video' ? 'mp4' : 'jpg';
|
||||
let encryptedPayload: EncryptedMediaPayload | null = null;
|
||||
let encryptedPayload: EncryptedMediaPayload;
|
||||
|
||||
// Compress videos on native platforms before upload
|
||||
if (mediaType === 'video' && Platform.OS !== 'web') {
|
||||
@@ -61,60 +73,108 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
// Try to encrypt the media if E2EE is available
|
||||
if (isE2EEAvailable && Platform.OS !== 'web') {
|
||||
// CRITICAL: Read keys directly from global store at encryption time
|
||||
// This ensures we always use the most current keys, not potentially stale hook state
|
||||
const currentKeys = useAuthStore.getState().encryptionKeys;
|
||||
|
||||
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk || !currentKeys.privateKeyJwk) {
|
||||
console.warn('[E2EE Media Upload] Keys not fully available in global store at encryption time');
|
||||
throw new Error('Media security not ready. Please check your connection.');
|
||||
}
|
||||
|
||||
const senderPublicKey = currentKeys.publicKeyJwk;
|
||||
const senderPrivateKey = currentKeys.privateKeyJwk;
|
||||
|
||||
// SAFETY CHECK: Verify the key pair works together before encrypting
|
||||
if (verifiedKeysVersionRef.current !== currentKeys.keysVersion) {
|
||||
console.log(
|
||||
`[E2EE Media Upload] Verifying sender key pair (version ${currentKeys.keysVersion})...`
|
||||
);
|
||||
const isValidKeyPair = await verifyKeyPair(
|
||||
senderPublicKey as RSAPublicKeyJWK,
|
||||
senderPrivateKey
|
||||
);
|
||||
|
||||
if (!isValidKeyPair) {
|
||||
console.error('[E2EE Media Upload] CRITICAL: Sender key pair verification failed!');
|
||||
throw new Error('Security check failed. Please restart the app.');
|
||||
}
|
||||
|
||||
verifiedKeysVersionRef.current = currentKeys.keysVersion;
|
||||
console.log('[E2EE Media Upload] Sender key pair verified successfully');
|
||||
}
|
||||
|
||||
console.log('[E2EE Media Upload] Using sender public key from global store, version:', currentKeys.keysVersion);
|
||||
|
||||
// Refresh partner's profile only when key is missing/invalid or TTL expires
|
||||
const resolvePartnerProfile = async () => {
|
||||
const cachedPartner = useAuthStore.getState().partner;
|
||||
const cachedKey = cachedPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
|
||||
const hasValidKey = cachedKey ? isValidPublicKeyJwk(cachedKey) : false;
|
||||
const now = Date.now();
|
||||
const shouldRefresh = !hasValidKey || (now - partnerRefreshRef.current) > PARTNER_REFRESH_TTL_MS;
|
||||
|
||||
if (!shouldRefresh) {
|
||||
return cachedPartner;
|
||||
}
|
||||
|
||||
console.log('[E2EE Media Upload] Refreshing partner profile...');
|
||||
try {
|
||||
// CRITICAL: Read keys directly from global store at encryption time
|
||||
// This ensures we always use the most current keys, not potentially stale hook state
|
||||
const currentKeys = useAuthStore.getState().encryptionKeys;
|
||||
|
||||
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk) {
|
||||
console.warn('[E2EE Media Upload] No keys available in global store at encryption time');
|
||||
throw new Error('No encryption keys available');
|
||||
}
|
||||
|
||||
const senderPublicKey = currentKeys.publicKeyJwk;
|
||||
console.log('[E2EE Media Upload] Using sender public key from global store, version:', currentKeys.keysVersion);
|
||||
|
||||
// Refresh partner's profile to get their latest public key
|
||||
console.log('[E2EE Media Upload] Refreshing partner profile...');
|
||||
const freshPartner = await refreshPartner();
|
||||
partnerRefreshRef.current = Date.now();
|
||||
return freshPartner ?? useAuthStore.getState().partner;
|
||||
} catch (refreshError) {
|
||||
console.warn('[E2EE Media Upload] Partner refresh failed, using cached key:', refreshError);
|
||||
partnerRefreshRef.current = Date.now();
|
||||
return cachedPartner;
|
||||
}
|
||||
};
|
||||
|
||||
const adminPublicKey = await getAdminPublicKey();
|
||||
const adminKeyId = await getAdminKeyId();
|
||||
let partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
|
||||
const freshPartner = await resolvePartnerProfile();
|
||||
|
||||
// Validate and sanitize partner's public key
|
||||
if (partnerPublicKey) {
|
||||
if (!isValidPublicKeyJwk(partnerPublicKey)) {
|
||||
console.warn('[E2EE Media Upload] Partner public key is invalid, treating as unavailable');
|
||||
partnerPublicKey = null;
|
||||
} else {
|
||||
// Sanitize to remove trailing dots from base64 fields
|
||||
partnerPublicKey = sanitizePublicKeyJwk(partnerPublicKey);
|
||||
}
|
||||
}
|
||||
let adminPublicKey: RSAPublicKeyJWK;
|
||||
let adminKeyId: string;
|
||||
|
||||
console.log('[E2EE Media Upload] Partner public key available:', !!partnerPublicKey);
|
||||
try {
|
||||
adminPublicKey = await getAdminPublicKey();
|
||||
adminKeyId = await getAdminKeyId();
|
||||
|
||||
encryptedPayload = await encryptMediaFile(
|
||||
fileUri,
|
||||
senderPublicKey as RSAPublicKeyJWK,
|
||||
partnerPublicKey ?? null,
|
||||
adminPublicKey,
|
||||
adminKeyId
|
||||
);
|
||||
|
||||
// Use the encrypted file for upload
|
||||
fileUri = encryptedPayload.encryptedFileUri;
|
||||
ext = 'enc'; // Encrypted files use .enc extension
|
||||
console.log('Media encrypted successfully');
|
||||
} catch (encryptError) {
|
||||
console.warn('Media encryption failed, uploading unencrypted:', encryptError);
|
||||
encryptedPayload = null;
|
||||
if (!isValidPublicKeyJwk(adminPublicKey)) {
|
||||
throw new Error('Admin public key is invalid');
|
||||
}
|
||||
} catch (adminKeyError) {
|
||||
console.error('[E2EE Media Upload] Failed to fetch admin key:', adminKeyError);
|
||||
throw new Error('Connection issue. Please try again.');
|
||||
}
|
||||
|
||||
let partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
|
||||
|
||||
// Validate and sanitize partner's public key
|
||||
if (partnerPublicKey) {
|
||||
if (!isValidPublicKeyJwk(partnerPublicKey)) {
|
||||
console.warn('[E2EE Media Upload] Partner public key is invalid, treating as unavailable');
|
||||
partnerPublicKey = null;
|
||||
} else {
|
||||
// Sanitize to remove trailing dots from base64 fields
|
||||
partnerPublicKey = sanitizePublicKeyJwk(partnerPublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[E2EE Media Upload] Partner public key available:', !!partnerPublicKey);
|
||||
|
||||
encryptedPayload = await encryptMediaFile(
|
||||
fileUri,
|
||||
senderPublicKey as RSAPublicKeyJWK,
|
||||
partnerPublicKey ?? null,
|
||||
adminPublicKey,
|
||||
adminKeyId
|
||||
);
|
||||
|
||||
// Use the encrypted file for upload
|
||||
fileUri = encryptedPayload.encryptedFileUri;
|
||||
ext = 'enc'; // Encrypted files use .enc extension
|
||||
console.log('Media encrypted successfully');
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
const response = await fetch(fileUri);
|
||||
const blob = await response.blob();
|
||||
@@ -135,21 +195,10 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
|
||||
fileBody = decode(base64);
|
||||
|
||||
if (mediaType !== 'video' && !encryptedPayload) {
|
||||
// Only use URI extension for non-videos and non-encrypted (videos are always mp4 after compression)
|
||||
const uriExt = fileUri.split('.').pop();
|
||||
if (uriExt && uriExt !== fileUri) {
|
||||
ext = uriExt.toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
|
||||
const contentType = encryptedPayload
|
||||
? 'application/octet-stream' // Encrypted files are binary
|
||||
: mediaType === 'video'
|
||||
? `video/${ext === 'mov' ? 'quicktime' : ext}`
|
||||
: `image/${ext === 'jpg' ? 'jpeg' : ext}`;
|
||||
const contentType = 'application/octet-stream';
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("chat-media")
|
||||
@@ -169,30 +218,18 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
// Store the message with encryption metadata if encrypted
|
||||
if (encryptedPayload) {
|
||||
await supabase.from("messages").insert({
|
||||
match_id: matchId,
|
||||
user_id: userId,
|
||||
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
|
||||
media_path: fileName,
|
||||
media_type: mediaType,
|
||||
media_expired: false,
|
||||
version: MESSAGE_VERSION_E2EE,
|
||||
encryption_iv: encryptedPayload.encryption_iv,
|
||||
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
|
||||
});
|
||||
} else {
|
||||
// Unencrypted upload (v1)
|
||||
await supabase.from("messages").insert({
|
||||
match_id: matchId,
|
||||
user_id: userId,
|
||||
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
|
||||
media_path: fileName,
|
||||
media_type: mediaType,
|
||||
media_expired: false,
|
||||
});
|
||||
}
|
||||
// Store the message with encryption metadata
|
||||
await supabase.from("messages").insert({
|
||||
match_id: matchId,
|
||||
user_id: userId,
|
||||
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
|
||||
media_path: fileName,
|
||||
media_type: mediaType,
|
||||
media_expired: false,
|
||||
version: MESSAGE_VERSION_E2EE,
|
||||
encryption_iv: encryptedPayload.encryption_iv,
|
||||
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
|
||||
});
|
||||
Events.mediaUploaded();
|
||||
|
||||
} catch (error) {
|
||||
|
||||
Reference in new issue
Block a user