mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Improve E2EE robustness and Android permissions
- Add IV validation and key sanitization for encryption - Add stale key repair for media decryption failures - Block READ_MEDIA_IMAGES/VIDEO permissions (use photo picker) - Prevent double-tap crashes in media picker - Clear media cache on logout - Persist versionCode/buildNumber in app.json - Bump Android targetSdk to 35
This commit is contained in:
1 parent
198cd80dfd
commit
348ef221db
13 files changed
+777
-106
No files matched your search
@@ -93,7 +93,7 @@ android {
|
||||
applicationId 'com.sauci.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionCode 9
|
||||
versionName "1.0.0"
|
||||
}
|
||||
signingConfigs {
|
||||
|
||||
@@ -5,7 +5,7 @@ buildscript {
|
||||
buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0'
|
||||
minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24')
|
||||
compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35')
|
||||
targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
|
||||
targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '35')
|
||||
kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||
|
||||
ndkVersion = "26.1.10909125"
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.sauci.app",
|
||||
"buildNumber": "5",
|
||||
"usesAppleSignIn": true,
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": [
|
||||
@@ -30,6 +31,7 @@
|
||||
"backgroundColor": "#1a1a2e"
|
||||
},
|
||||
"package": "com.sauci.app",
|
||||
"versionCode": 10,
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"permissions": [
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
@@ -45,6 +47,17 @@
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"blockedPermissions": [
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"android.permission.READ_MEDIA_VIDEO"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"expo-router",
|
||||
"expo-apple-authentication",
|
||||
[
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
getAdminPublicKey,
|
||||
getAdminKeyId,
|
||||
MESSAGE_VERSION_E2EE,
|
||||
isValidPublicKeyJwk,
|
||||
sanitizePublicKeyJwk,
|
||||
} from '../../../lib/encryption';
|
||||
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
|
||||
import { useEncryptionKeys } from '../../../hooks';
|
||||
@@ -80,7 +82,18 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
|
||||
const adminPublicKey = await getAdminPublicKey();
|
||||
const adminKeyId = await getAdminKeyId();
|
||||
const partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
|
||||
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);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { decryptMediaFile, cleanupDecryptedMedia, MESSAGE_VERSION_E2EE } from '../lib/encryption';
|
||||
import { decryptMediaFile, cleanupDecryptedMedia, MESSAGE_VERSION_E2EE, repairStaleKey } from '../lib/encryption';
|
||||
import type { KeysMetadata, RSAPrivateKeyJWK } from '../lib/encryption';
|
||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||
import { getCachedSignedUrl, getStoragePath } from '../lib/imageCache';
|
||||
@@ -278,7 +278,83 @@ export function useDecryptedMedia({
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[E2EE Media] Message ${messageId}: Decryption failed:`, err);
|
||||
const error = err instanceof Error ? err : new Error('Unknown error');
|
||||
console.error(`[E2EE Media] Message ${messageId}: Decryption failed:`, error);
|
||||
|
||||
// Check if this is a stale key failure (message encrypted with old key)
|
||||
const isStaleKeyFailure =
|
||||
error.name === 'OperationError' ||
|
||||
error.message.includes('OperationError') ||
|
||||
error.message.includes('decryption failed') ||
|
||||
error.message.includes('decrypt error') ||
|
||||
error.message.includes('error in DoCipher');
|
||||
|
||||
// Attempt stale key repair if not already attempted
|
||||
if (isStaleKeyFailure && !repairAttemptedByMessageId.current.has(messageId)) {
|
||||
console.log(`[E2EE Media] Message ${messageId}: Attempting stale key repair...`);
|
||||
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: true,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
repairAttemptedByMessageId.current.add(messageId);
|
||||
|
||||
try {
|
||||
const repairResult = await repairStaleKey(messageId);
|
||||
|
||||
if (repairResult.success) {
|
||||
console.log(`[E2EE Media] Message ${messageId}: Stale key repaired, retrying decryption...`);
|
||||
|
||||
// Fetch updated message and retry
|
||||
const { data: refreshed } = await supabase
|
||||
.from('messages')
|
||||
.select('keys_metadata')
|
||||
.eq('id', messageId)
|
||||
.maybeSingle();
|
||||
|
||||
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
|
||||
if (refreshedMetadata && encryptionIv && mediaPath) {
|
||||
const storagePath = getStoragePath(mediaPath);
|
||||
const encryptedUrl = await getCachedSignedUrl(storagePath);
|
||||
|
||||
if (encryptedUrl) {
|
||||
const decryptedUri = await decryptMediaFile(
|
||||
encryptedUrl,
|
||||
encryptionIv,
|
||||
refreshedMetadata,
|
||||
privateKeyJwk as RSAPrivateKeyJWK,
|
||||
!isMe,
|
||||
mediaType,
|
||||
);
|
||||
|
||||
const cacheKey = `${messageId}-${mediaPath}`;
|
||||
decryptedMediaCache.set(cacheKey, decryptedUri);
|
||||
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: decryptedUri,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(`[E2EE Media] Message ${messageId}: Stale key repair failed:`, repairResult.error);
|
||||
}
|
||||
} catch (repairErr) {
|
||||
console.error(`[E2EE Media] Message ${messageId}: Stale key repair threw:`, repairErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: null,
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
encryptTextMessage,
|
||||
getAdminPublicKey,
|
||||
getAdminKeyId,
|
||||
isValidPublicKeyJwk,
|
||||
sanitizePublicKeyJwk,
|
||||
} from '../lib/encryption';
|
||||
import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption';
|
||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||
@@ -87,11 +89,22 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
|
||||
const adminKeyId = await getAdminKeyId();
|
||||
|
||||
// Get partner's public key from the freshly fetched profile
|
||||
const partnerPublicKey = freshPartner?.public_key_jwk as
|
||||
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 Send] Partner public key is invalid, treating as unavailable');
|
||||
partnerPublicKey = null;
|
||||
} else {
|
||||
// Sanitize to remove trailing dots from base64 fields (react-native-quick-crypto bug)
|
||||
partnerPublicKey = sanitizePublicKeyJwk(partnerPublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[E2EE Send] Partner public key available:', !!partnerPublicKey);
|
||||
|
||||
// Encrypt the message using the key from global store
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
|
||||
@@ -40,6 +40,10 @@ const DEFAULT_CONFIG: Required<MediaPickerConfig> = {
|
||||
*/
|
||||
export const useMediaPicker = (config?: MediaPickerConfig) => {
|
||||
const settings = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
// Track if a picker is currently active to prevent double-presentation crashes
|
||||
// This is common on iOS if the user double-taps or if an action fires twice
|
||||
const isPresentingRef = useRef(false);
|
||||
|
||||
/**
|
||||
* Request camera permissions with user-friendly error handling.
|
||||
@@ -62,26 +66,37 @@ export const useMediaPicker = (config?: MediaPickerConfig) => {
|
||||
* @returns MediaResult if selected, null if cancelled
|
||||
*/
|
||||
const pickMedia = useCallback(async (): Promise<MediaResult | null> => {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||
quality: settings.imageQuality,
|
||||
videoMaxDuration: settings.libraryVideoMaxDuration,
|
||||
});
|
||||
if (isPresentingRef.current) return null;
|
||||
isPresentingRef.current = true;
|
||||
|
||||
if (result.canceled) {
|
||||
return null;
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||
quality: settings.imageQuality,
|
||||
videoMaxDuration: settings.libraryVideoMaxDuration,
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
const isVideo = asset.type === 'video';
|
||||
|
||||
return {
|
||||
uri: asset.uri,
|
||||
mediaType: isVideo ? 'video' : 'image',
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
duration: asset.duration ?? undefined,
|
||||
};
|
||||
} finally {
|
||||
// Add a small delay to ensure the modal dismissal animation completes
|
||||
// before allowing another presentation
|
||||
setTimeout(() => {
|
||||
isPresentingRef.current = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
const isVideo = asset.type === 'video';
|
||||
|
||||
return {
|
||||
uri: asset.uri,
|
||||
mediaType: isVideo ? 'video' : 'image',
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
duration: asset.duration ?? undefined,
|
||||
};
|
||||
}, [settings.imageQuality, settings.libraryVideoMaxDuration]);
|
||||
|
||||
/**
|
||||
@@ -89,26 +104,43 @@ export const useMediaPicker = (config?: MediaPickerConfig) => {
|
||||
* @returns MediaResult if captured, null if cancelled or permission denied
|
||||
*/
|
||||
const takePhoto = useCallback(async (): Promise<MediaResult | null> => {
|
||||
const hasPermission = await requestCameraPermission();
|
||||
if (!hasPermission) {
|
||||
return null;
|
||||
if (isPresentingRef.current) return null;
|
||||
isPresentingRef.current = true;
|
||||
|
||||
try {
|
||||
const hasPermission = await requestCameraPermission();
|
||||
if (!hasPermission) {
|
||||
isPresentingRef.current = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
quality: settings.imageQuality,
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
return {
|
||||
uri: asset.uri,
|
||||
mediaType: 'image',
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
};
|
||||
} catch (error) {
|
||||
// Ensure we reset state on error
|
||||
isPresentingRef.current = false;
|
||||
throw error;
|
||||
} finally {
|
||||
// Only reset if we didn't reset in the permission check
|
||||
if (isPresentingRef.current) {
|
||||
setTimeout(() => {
|
||||
isPresentingRef.current = false;
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
quality: settings.imageQuality,
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
return {
|
||||
uri: asset.uri,
|
||||
mediaType: 'image',
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
};
|
||||
}, [settings.imageQuality, requestCameraPermission]);
|
||||
|
||||
/**
|
||||
@@ -116,29 +148,44 @@ export const useMediaPicker = (config?: MediaPickerConfig) => {
|
||||
* @returns MediaResult if recorded, null if cancelled or permission denied
|
||||
*/
|
||||
const recordVideo = useCallback(async (): Promise<MediaResult | null> => {
|
||||
const hasPermission = await requestCameraPermission();
|
||||
if (!hasPermission) {
|
||||
return null;
|
||||
if (isPresentingRef.current) return null;
|
||||
isPresentingRef.current = true;
|
||||
|
||||
try {
|
||||
const hasPermission = await requestCameraPermission();
|
||||
if (!hasPermission) {
|
||||
isPresentingRef.current = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
|
||||
quality: settings.imageQuality,
|
||||
videoMaxDuration: settings.cameraVideoMaxDuration,
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
return {
|
||||
uri: asset.uri,
|
||||
mediaType: 'video',
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
duration: asset.duration ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
isPresentingRef.current = false;
|
||||
throw error;
|
||||
} finally {
|
||||
if (isPresentingRef.current) {
|
||||
setTimeout(() => {
|
||||
isPresentingRef.current = false;
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
|
||||
quality: settings.imageQuality,
|
||||
videoMaxDuration: settings.cameraVideoMaxDuration,
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
return {
|
||||
uri: asset.uri,
|
||||
mediaType: 'video',
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
duration: asset.duration ?? undefined,
|
||||
};
|
||||
}, [settings.imageQuality, settings.cameraVideoMaxDuration, requestCameraPermission]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -49,7 +49,13 @@ export async function decryptMediaFile(
|
||||
);
|
||||
const encryptedData = base64ToArrayBuffer(encryptedBase64);
|
||||
|
||||
// 3. Unwrap AES key
|
||||
// 3. Validate and decode IV (must be exactly 12 bytes for AES-GCM)
|
||||
const iv = base64ToUint8Array(encryptionIv);
|
||||
if (iv.length !== 12) {
|
||||
throw new Error(`Invalid IV length: expected 12 bytes, got ${iv.length}`);
|
||||
}
|
||||
|
||||
// 4. Unwrap AES key
|
||||
const wrappedKeyBase64 = isRecipient
|
||||
? keysMetadata.recipient_wrapped_key
|
||||
: keysMetadata.sender_wrapped_key;
|
||||
@@ -61,11 +67,10 @@ export async function decryptMediaFile(
|
||||
const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk);
|
||||
const aesKey = await importAESKey(rawAesKey);
|
||||
|
||||
// 4. Decrypt file
|
||||
const iv = base64ToUint8Array(encryptionIv);
|
||||
// 5. Decrypt file
|
||||
const decryptedData = await decryptAES(encryptedData, aesKey, iv);
|
||||
|
||||
// 5. Write decrypted file
|
||||
// 6. Write decrypted file
|
||||
const ext = mediaType === 'video' ? 'mp4' : 'jpg';
|
||||
const decryptedUri = `${FileSystem.cacheDirectory}${Date.now()}_decrypted.${ext}`;
|
||||
|
||||
|
||||
@@ -35,18 +35,23 @@ export async function decryptTextMessage(
|
||||
throw new Error('No wrapped key available for decryption');
|
||||
}
|
||||
|
||||
// 2. Unwrap AES key using RSA private key
|
||||
// 2. Validate and decode IV (must be exactly 12 bytes for AES-GCM)
|
||||
const iv = base64ToUint8Array(encryptionIv);
|
||||
if (iv.length !== 12) {
|
||||
throw new Error(`Invalid IV length: expected 12 bytes, got ${iv.length}`);
|
||||
}
|
||||
|
||||
// 3. Unwrap AES key using RSA private key
|
||||
const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk);
|
||||
|
||||
// 3. Import AES key
|
||||
// 4. Import AES key
|
||||
const aesKey = await importAESKey(rawAesKey);
|
||||
|
||||
// 4. Decrypt content
|
||||
const iv = base64ToUint8Array(encryptionIv);
|
||||
// 5. Decrypt content
|
||||
const ciphertext = base64ToArrayBuffer(encryptedContent);
|
||||
|
||||
const plaintext = await decryptAES(ciphertext, aesKey, iv);
|
||||
|
||||
// 5. Decode to string
|
||||
// 6. Decode to string
|
||||
return new TextDecoder().decode(plaintext);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { Events } from "../lib/analytics";
|
||||
import { clearKeys } from "../lib/encryption";
|
||||
import { clearDecryptedMediaCache } from "../hooks/useDecryptedMedia";
|
||||
import type { Profile, Couple } from "@/types";
|
||||
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from "../lib/encryption/types";
|
||||
|
||||
@@ -181,6 +182,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
console.error("Failed to clear encryption keys:", error);
|
||||
}
|
||||
|
||||
// Clear decrypted media cache to free storage
|
||||
try {
|
||||
await clearDecryptedMediaCache();
|
||||
} catch (error) {
|
||||
console.error("Failed to clear media cache:", error);
|
||||
}
|
||||
|
||||
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
||||
set({
|
||||
user: null,
|
||||
|
||||
Reference in new issue
Block a user