diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 6f223c6..3a3df77 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -51,11 +51,7 @@ "expo-build-properties", { "android": { - "targetSdkVersion": 35, - "blockedPermissions": [ - "android.permission.READ_MEDIA_IMAGES", - "android.permission.READ_MEDIA_VIDEO" - ] + "targetSdkVersion": 35 } } ], diff --git a/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj b/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj index 9a56070..8550191 100644 --- a/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -19,7 +19,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 0FE9A3D4025061824A4C95F7 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 0FE9A3D4025061824A4C95F7 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = ""; }; 13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = ""; }; @@ -390,6 +390,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = BXVPBATDZY; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", @@ -397,7 +398,10 @@ ); INFOPLIST_FILE = Sauci/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MARKETING_VERSION = 1.0; OTHER_LDFLAGS = ( "$(inherited)", @@ -423,9 +427,13 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = BXVPBATDZY; INFOPLIST_FILE = Sauci/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MARKETING_VERSION = 1.0; OTHER_LDFLAGS = ( "$(inherited)", @@ -490,14 +498,14 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.1; - LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -546,13 +554,13 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.1; - LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = NO; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/apps/mobile/src/features/chat/ChatScreen.tsx b/apps/mobile/src/features/chat/ChatScreen.tsx index 5cf5986..05ade07 100644 --- a/apps/mobile/src/features/chat/ChatScreen.tsx +++ b/apps/mobile/src/features/chat/ChatScreen.tsx @@ -184,7 +184,11 @@ export const ChatScreen: React.FC = () => { } } catch (err) { console.error("Error sending message:", err); - Alert.alert("Error", "Failed to send message"); + // Show the actual error message if it's user-friendly (from encryption) + const errorMessage = err instanceof Error && err.message + ? err.message + : "Failed to send message"; + Alert.alert("Error", errorMessage); setInputText(content); } }; diff --git a/apps/mobile/src/hooks/useEncryptedSend.ts b/apps/mobile/src/hooks/useEncryptedSend.ts index e0b6d19..490b92c 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 } from 'react'; +import { useCallback, useState, useRef } from 'react'; import { Platform } from 'react-native'; import { encryptTextMessage, @@ -16,6 +16,7 @@ import { getAdminKeyId, isValidPublicKeyJwk, sanitizePublicKeyJwk, + verifyKeyPair, } from '../lib/encryption'; import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption'; import { useEncryptionKeys } from './useEncryptionKeys'; @@ -42,6 +43,9 @@ export function useEncryptedSend(): UseEncryptedSendReturn { const [isEncrypting, setIsEncrypting] = useState(false); const [error, setError] = useState(null); + // Track whether we've verified the key pair this session + const keyPairVerifiedRef = useRef(false); + const { publicKeyJwk, hasKeys, isLoading } = useEncryptionKeys(); const refreshPartner = useAuthStore((s) => s.refreshPartner); @@ -63,12 +67,35 @@ export function useEncryptedSend(): UseEncryptedSendReturn { // 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 Send] No keys available in global store at encryption time'); - return null; + if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk || !currentKeys.privateKeyJwk) { + const keysError = new Error('Encryption keys not ready. Please wait a moment and try again.'); + console.warn('[E2EE Send] Keys not fully available in global store'); + setError(keysError); + throw keysError; } const senderPublicKey = currentKeys.publicKeyJwk; + const senderPrivateKey = currentKeys.privateKeyJwk; + + // SAFETY CHECK: Verify the key pair works together before encrypting + // This prevents creating unrecoverable messages if keys are mismatched + if (!keyPairVerifiedRef.current) { + console.log('[E2EE Send] Verifying sender key pair...'); + const isValidKeyPair = await verifyKeyPair( + senderPublicKey as RSAPublicKeyJWK, + senderPrivateKey + ); + + if (!isValidKeyPair) { + const keyError = new Error('Encryption key verification failed. Please restart the app and try again.'); + console.error('[E2EE Send] CRITICAL: Sender key pair verification failed!'); + setError(keyError); + throw keyError; + } + + keyPairVerifiedRef.current = true; + console.log('[E2EE Send] Sender key pair verified successfully'); + } console.log( '[E2EE Send] Using sender public key from global store, version:', @@ -85,8 +112,33 @@ export function useEncryptedSend(): UseEncryptedSendReturn { const freshPartner = await refreshPartner(); // Get admin key for moderation access - const adminPublicKey = await getAdminPublicKey(); - const adminKeyId = await getAdminKeyId(); + // SAFETY: Validate the admin key before using it + let adminPublicKey: RSAPublicKeyJWK; + let adminKeyId: string; + + try { + adminPublicKey = await getAdminPublicKey(); + adminKeyId = await getAdminKeyId(); + + // Validate admin key structure + if (!isValidPublicKeyJwk(adminPublicKey)) { + const adminError = new Error('Unable to secure message. Please check your connection and try again.'); + console.error('[E2EE Send] Admin public key is invalid'); + setIsEncrypting(false); + setError(adminError); + throw adminError; + } + } catch (adminKeyError) { + // Re-throw if already a user-facing error + if (adminKeyError instanceof Error && adminKeyError.message.includes('Unable to secure')) { + throw adminKeyError; + } + const fetchError = new Error('Unable to secure message. Please check your connection and try again.'); + console.error('[E2EE Send] Failed to fetch admin key:', adminKeyError); + setIsEncrypting(false); + setError(fetchError); + throw fetchError; + } // Get partner's public key from the freshly fetched profile let partnerPublicKey = freshPartner?.public_key_jwk as @@ -124,7 +176,8 @@ export function useEncryptedSend(): UseEncryptedSendReturn { console.error('[E2EE Send] Failed to encrypt message:', err); setError(err as Error); setIsEncrypting(false); - return null; + // Re-throw to block sending - never fall back to plaintext + throw err; } }, [isE2EEAvailable, refreshPartner] diff --git a/apps/mobile/src/lib/encryption/crypto.ts b/apps/mobile/src/lib/encryption/crypto.ts index 135387d..33550d1 100644 --- a/apps/mobile/src/lib/encryption/crypto.ts +++ b/apps/mobile/src/lib/encryption/crypto.ts @@ -150,7 +150,7 @@ export async function wrapAESKey( /** * Unwrap (decrypt) an AES key with an RSA private key - * + * * @param wrappedKeyBase64 - The wrapped key as base64 string * @param privateKeyJwk - The user's RSA private key * @returns The unwrapped AES key as ArrayBuffer @@ -162,10 +162,65 @@ export async function unwrapAESKey( const subtle = getSubtle(); const rsaKey = await importPrivateKey(privateKeyJwk); const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64); - + return await subtle.decrypt( { name: KEY_WRAP_ALGORITHM }, rsaKey, wrappedKey ); } + +/** + * Verify that a public/private key pair actually work together. + * This catches key mismatches that could cause unrecoverable encrypted messages. + * + * @param publicKeyJwk - The RSA public key + * @param privateKeyJwk - The RSA private key + * @returns true if the keys work together, false otherwise + */ +export async function verifyKeyPair( + publicKeyJwk: RSAPublicKeyJWK, + privateKeyJwk: RSAPrivateKeyJWK +): Promise { + try { + const subtle = getSubtle(); + + // Create test data (32 bytes - same as AES-256 key) + const testData = getRandomValues(new Uint8Array(32)); + + // Import keys + const publicKey = await importPublicKey(publicKeyJwk); + const privateKey = await importPrivateKey(privateKeyJwk); + + // Encrypt with public key + const encrypted = await subtle.encrypt( + { name: KEY_WRAP_ALGORITHM }, + publicKey, + testData as BufferSource + ); + + // Decrypt with private key + const decrypted = await subtle.decrypt( + { name: KEY_WRAP_ALGORITHM }, + privateKey, + encrypted + ); + + // Verify the data matches + const decryptedArray = new Uint8Array(decrypted); + if (decryptedArray.length !== testData.length) { + return false; + } + + for (let i = 0; i < testData.length; i++) { + if (testData[i] !== decryptedArray[i]) { + return false; + } + } + + return true; + } catch (error) { + console.error('[E2EE] Key pair verification failed:', error); + return false; + } +} diff --git a/apps/mobile/src/lib/encryption/index.ts b/apps/mobile/src/lib/encryption/index.ts index 0cc90f9..f1bc6c0 100644 --- a/apps/mobile/src/lib/encryption/index.ts +++ b/apps/mobile/src/lib/encryption/index.ts @@ -65,3 +65,6 @@ export { triggerKeyRotation } from './triggerKeyRotation'; // Stale Key Repair export { repairStaleKey } from './repairStaleKey'; + +// Crypto Operations +export { verifyKeyPair } from './crypto';