mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 18:19:44 +00:00
Implement E2EE and message content moderation
This commit is contained in:
1 parent
e1ff87dc03
commit
198cd80dfd
74 files changed
+12939
-2178
No files matched your search
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* useDecryptedMedia Hook
|
||||
*
|
||||
* React hook for decrypting E2EE encrypted media files (images/videos).
|
||||
* Handles downloading, decrypting, and caching decrypted media.
|
||||
*/
|
||||
|
||||
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 type { KeysMetadata, RSAPrivateKeyJWK } from '../lib/encryption';
|
||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||
import { getCachedSignedUrl, getStoragePath } from '../lib/imageCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
||||
|
||||
export type DecryptedMediaErrorCode =
|
||||
| 'E2EE_NOT_SUPPORTED'
|
||||
| 'E2EE_KEYS_UNAVAILABLE'
|
||||
| 'E2EE_MISSING_FIELDS'
|
||||
| 'E2EE_PENDING_RECIPIENT_KEY'
|
||||
| 'E2EE_DECRYPT_FAILED';
|
||||
|
||||
// In-memory cache to avoid re-decrypting the same media
|
||||
const decryptedMediaCache = new Map<string, string>();
|
||||
|
||||
interface DecryptedMediaState {
|
||||
/** Local file URI to the decrypted media (or null if not ready) */
|
||||
uri: string | null;
|
||||
/** Whether decryption is in progress */
|
||||
isDecrypting: boolean;
|
||||
/** Error if decryption failed */
|
||||
error: Error | null;
|
||||
/** Optional error code for UX */
|
||||
errorCode?: DecryptedMediaErrorCode;
|
||||
}
|
||||
|
||||
interface UseDecryptedMediaOptions {
|
||||
/** The message ID (used for cache key) */
|
||||
messageId: string;
|
||||
/** Path to the media file in storage */
|
||||
mediaPath: string | null;
|
||||
/** Message version (1 = plaintext, 2 = encrypted) */
|
||||
version: number | null | undefined;
|
||||
/** Encryption IV (required for v2) */
|
||||
encryptionIv: string | null | undefined;
|
||||
/** Keys metadata containing wrapped keys (required for v2) */
|
||||
keysMetadata: KeysMetadata | null | undefined;
|
||||
/** Whether current user is the sender */
|
||||
isMe: boolean;
|
||||
/** Media type */
|
||||
mediaType: 'image' | 'video';
|
||||
/** Whether media should be fetched (e.g., not hidden) */
|
||||
shouldFetch: boolean;
|
||||
}
|
||||
|
||||
export type UseDecryptedMediaResult = DecryptedMediaState & {
|
||||
isEncrypted: boolean;
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to decrypt encrypted media files
|
||||
*/
|
||||
export function useDecryptedMedia({
|
||||
messageId,
|
||||
mediaPath,
|
||||
version,
|
||||
encryptionIv,
|
||||
keysMetadata,
|
||||
isMe,
|
||||
mediaType,
|
||||
shouldFetch,
|
||||
}: UseDecryptedMediaOptions): UseDecryptedMediaResult {
|
||||
const [state, setState] = useState<DecryptedMediaState>({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
||||
const isMounted = useRef(true);
|
||||
|
||||
const repairAttemptedByMessageId = useRef(new Set<string>());
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
repairAttemptedByMessageId.current.delete(messageId);
|
||||
setRetryNonce((n) => n + 1);
|
||||
}, [messageId]);
|
||||
|
||||
// Determine if this is encrypted media
|
||||
const isEncrypted = version === MESSAGE_VERSION_E2EE && (mediaPath?.endsWith('.enc') ?? false);
|
||||
|
||||
const decryptMedia = useCallback(async () => {
|
||||
// No media path - nothing to do
|
||||
if (!mediaPath || !shouldFetch) {
|
||||
setState({ uri: null, isDecrypting: false, error: null, errorCode: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[E2EE Media] Message ${messageId}: Starting decrypt - version=${version}, mediaPath=${mediaPath}, isEncrypted=${isEncrypted}`,
|
||||
);
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `${messageId}-${mediaPath}`;
|
||||
const cachedUri = decryptedMediaCache.get(cacheKey);
|
||||
if (cachedUri) {
|
||||
try {
|
||||
const fileInfo = await FileSystem.getInfoAsync(cachedUri);
|
||||
if (fileInfo.exists) {
|
||||
console.log(`[E2EE Media] Message ${messageId}: Using cached decrypted file`);
|
||||
setState({ uri: cachedUri, isDecrypting: false, error: null, errorCode: undefined });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
decryptedMediaCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// v1 (plaintext) or non-.enc files: Use existing signed URL flow
|
||||
if (!isEncrypted) {
|
||||
console.log(`[E2EE Media] Message ${messageId}: Not encrypted, using signed URL flow`);
|
||||
try {
|
||||
const storagePath = getStoragePath(mediaPath);
|
||||
const signedUrl = await getCachedSignedUrl(storagePath);
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: signedUrl,
|
||||
isDecrypting: false,
|
||||
error: signedUrl ? null : new Error('Failed to get signed URL'),
|
||||
errorCode: signedUrl ? undefined : 'E2EE_DECRYPT_FAILED',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[E2EE Media] Message ${messageId}: Failed to get signed URL:`, err);
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: err as Error,
|
||||
errorCode: 'E2EE_DECRYPT_FAILED',
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// v2 (encrypted) media - requires decryption
|
||||
console.log(`[E2EE Media] Message ${messageId}: Encrypted media, starting decryption`);
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Failed to load media'),
|
||||
errorCode: 'E2EE_NOT_SUPPORTED',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for keys to load
|
||||
if (keysLoading) {
|
||||
console.log(`[E2EE Media] Message ${messageId}: Keys still loading...`);
|
||||
setState((prev) => ({ ...prev, isDecrypting: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasKeys || !privateKeyJwk) {
|
||||
console.log(
|
||||
`[E2EE Media] Message ${messageId}: No keys available - hasKeys=${hasKeys}, hasPrivateKey=${!!privateKeyJwk}`,
|
||||
);
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Failed to load media'),
|
||||
errorCode: 'E2EE_KEYS_UNAVAILABLE',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required encryption fields
|
||||
if (!encryptionIv || !keysMetadata) {
|
||||
console.log(
|
||||
`[E2EE Media] Message ${messageId}: Missing encryption metadata - iv=${!!encryptionIv}, keysMetadata=${!!keysMetadata}`,
|
||||
);
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Failed to load media'),
|
||||
errorCode: 'E2EE_MISSING_FIELDS',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If the recipient key is missing, try server-assisted repair (admin recovery model).
|
||||
let effectiveKeysMetadata: KeysMetadata | null = keysMetadata;
|
||||
|
||||
const getWrappedKey = (metadata: KeysMetadata | null): string | undefined =>
|
||||
isMe ? metadata?.sender_wrapped_key : metadata?.recipient_wrapped_key;
|
||||
|
||||
let wrappedKey = getWrappedKey(effectiveKeysMetadata);
|
||||
|
||||
if (!wrappedKey) {
|
||||
console.log(
|
||||
`[E2EE Media] Message ${messageId}: No wrapped key for ${isMe ? 'sender' : 'recipient'} - pending_recipient=${(effectiveKeysMetadata as any)?.pending_recipient}`,
|
||||
);
|
||||
|
||||
if (!isMe) {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: true,
|
||||
error: null,
|
||||
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
||||
});
|
||||
|
||||
if (!repairAttemptedByMessageId.current.has(messageId)) {
|
||||
repairAttemptedByMessageId.current.add(messageId);
|
||||
await reencryptPendingMessages();
|
||||
}
|
||||
|
||||
const { data: refreshed } = await supabase
|
||||
.from('messages')
|
||||
.select('keys_metadata')
|
||||
.eq('id', messageId)
|
||||
.maybeSingle();
|
||||
|
||||
effectiveKeysMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
wrappedKey = getWrappedKey(effectiveKeysMetadata);
|
||||
}
|
||||
|
||||
if (!wrappedKey) {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isDecrypting: true, error: null, errorCode: undefined }));
|
||||
|
||||
try {
|
||||
// Get signed URL for the encrypted file
|
||||
const storagePath = getStoragePath(mediaPath);
|
||||
console.log(`[E2EE Media] Message ${messageId}: Getting signed URL for ${storagePath}`);
|
||||
const encryptedUrl = await getCachedSignedUrl(storagePath);
|
||||
|
||||
if (!encryptedUrl) {
|
||||
throw new Error('Failed to load media');
|
||||
}
|
||||
|
||||
console.log(`[E2EE Media] Message ${messageId}: Decrypting ${mediaType}...`);
|
||||
|
||||
const decryptedUri = await decryptMediaFile(
|
||||
encryptedUrl,
|
||||
encryptionIv,
|
||||
effectiveKeysMetadata as KeysMetadata,
|
||||
privateKeyJwk as RSAPrivateKeyJWK,
|
||||
!isMe, // isRecipient
|
||||
mediaType,
|
||||
);
|
||||
|
||||
console.log(`[E2EE Media] Message ${messageId}: Decryption successful - ${decryptedUri}`);
|
||||
|
||||
decryptedMediaCache.set(cacheKey, decryptedUri);
|
||||
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: decryptedUri,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[E2EE Media] Message ${messageId}: Decryption failed:`, err);
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Failed to load media'),
|
||||
errorCode: 'E2EE_DECRYPT_FAILED',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
messageId,
|
||||
mediaPath,
|
||||
version,
|
||||
encryptionIv,
|
||||
keysMetadata,
|
||||
isMe,
|
||||
mediaType,
|
||||
shouldFetch,
|
||||
isEncrypted,
|
||||
privateKeyJwk,
|
||||
hasKeys,
|
||||
keysLoading,
|
||||
retryNonce,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
decryptMedia();
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, [decryptMedia, retryNonce]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
isEncrypted,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the decrypted media cache
|
||||
* Call this when logging out or when memory is low
|
||||
*/
|
||||
export async function clearDecryptedMediaCache(): Promise<void> {
|
||||
for (const uri of decryptedMediaCache.values()) {
|
||||
await cleanupDecryptedMedia(uri);
|
||||
}
|
||||
decryptedMediaCache.clear();
|
||||
}
|
||||
Reference in new issue
Block a user