Add pack icon system and E2EE removal plan

- Replace emoji icons with Ionicons for professional appearance
- Improve E2EE key recovery with better diagnostics
- Add analytics for pack icon events
- Document E2EE removal plan for future simplification

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 4.5 committed 2026-01-08 15:34:25 +00:00
1 parent 953bd7c03d
commit 43fa7033dc
14 files changed
+1246 -194

No files matched your search

+11 -10
View File
@@ -18,6 +18,7 @@ import { GradientBackground } from "../../../src/components/ui";
import { Paywall } from "../../../src/components/paywall";
import { PackTeaser } from "../../../src/components/PackTeaser";
import { colors, spacing, radius, typography } from "../../../src/theme";
import { getPackIconName } from "../../../src/lib/packIcons";
import type { QuestionPack, Category } from "../../../src/types";
const HEADER_MAX_HEIGHT = 180;
@@ -72,7 +73,11 @@ function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPre
<Pressable onPress={onPress} style={styles.packRow}>
<View style={styles.packRowMain}>
<View style={[styles.packIcon, isEnabled && styles.packIconEnabled]}>
<Text style={styles.packIconText}>{pack.icon || "📦"}</Text>
<Ionicons
name={getPackIconName(pack.icon)}
size={20}
color={isEnabled ? colors.primary : colors.textSecondary}
/>
</View>
<View style={styles.packContent}>
@@ -206,9 +211,11 @@ function CategorySection({
<View style={styles.categorySection}>
<Pressable style={styles.categoryHeader} onPress={handleToggleCollapse}>
<View style={styles.categoryHeaderLeft}>
{category.icon && (
<Text style={styles.categoryIcon}>{category.icon}</Text>
)}
<Ionicons
name={getPackIconName(category.icon)}
size={16}
color={colors.textSecondary}
/>
<Text style={styles.categoryName}>{category.name}</Text>
{enabledCount > 0 && (
<View style={styles.categoryBadge}>
@@ -616,9 +623,6 @@ const styles = StyleSheet.create({
alignItems: "center",
gap: spacing.sm,
},
categoryIcon: {
fontSize: 18,
},
categoryName: {
...typography.headline,
color: colors.text,
@@ -672,9 +676,6 @@ const styles = StyleSheet.create({
backgroundColor: "rgba(233, 69, 96, 0.1)",
borderColor: "rgba(233, 69, 96, 0.2)",
},
packIconText: {
fontSize: 20,
},
packContent: {
flex: 1,
},
+6 -4
View File
@@ -16,6 +16,7 @@ import Animated, {
} from "react-native-reanimated";
import { GradientBackground, GlassCard } from "../../src/components/ui";
import { colors, gradients, spacing, radius, typography } from "../../src/theme";
import { getPackIconName } from "../../src/lib/packIcons";
import { Question } from "../../src/types";
type TooltipType = 'initiator' | 'coupleType' | null;
@@ -259,7 +260,11 @@ export default function PackDetailsScreen() {
style={StyleSheet.absoluteFill}
/>
)}
<Text style={styles.emoji}>{pack?.icon || "📦"}</Text>
<Ionicons
name={getPackIconName(pack?.icon)}
size={32}
color={pack?.is_premium ? colors.premium.gold : colors.textSecondary}
/>
</View>
{/* Crown for premium */}
{pack?.is_premium && (
@@ -575,9 +580,6 @@ const styles = StyleSheet.create({
shadowRadius: 4,
elevation: 4,
},
emoji: {
fontSize: 36,
},
title: {
...typography.largeTitle,
color: colors.text,
+6 -4
View File
@@ -19,6 +19,7 @@ import Animated, {
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { supabase } from "../lib/supabase";
import { getPackIconName } from "../lib/packIcons";
import { colors, gradients, spacing, radius, typography } from "../theme";
interface PackTeaserProps {
@@ -159,7 +160,11 @@ export function PackTeaser({
colors={['rgba(212, 175, 55, 0.15)', 'rgba(184, 134, 11, 0.1)']}
style={StyleSheet.absoluteFill}
/>
<Text style={styles.packEmoji}>{packIcon || "📦"}</Text>
<Ionicons
name={getPackIconName(packIcon)}
size={36}
color={colors.premium.gold}
/>
</View>
{/* Crown badge */}
<View style={styles.crownBadge}>
@@ -370,9 +375,6 @@ const styles = StyleSheet.create({
shadowRadius: 4,
elevation: 4,
},
packEmoji: {
fontSize: 40,
},
title: {
...typography.title1,
color: colors.text,
+42 -1
View File
@@ -34,7 +34,7 @@ export const ChatScreen: React.FC = () => {
const matchId = id as string;
const router = useRouter();
const insets = useSafeAreaInsets();
const { user, partner } = useAuthStore();
const { user, partner, encryptionKeys } = useAuthStore();
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
const [match, setMatch] = useState<any>(null);
const [inputText, setInputText] = useState("");
@@ -77,6 +77,20 @@ export const ChatScreen: React.FC = () => {
// E2EE encryption hook for sending messages
const { encryptMessage, isE2EEAvailable, isAdminKeyReady, isEncrypting } = useEncryptedSend();
const secureReady = isE2EEAvailable && isAdminKeyReady;
const recoveryMessage = (() => {
if (!encryptionKeys.isRecovering) return null;
switch (encryptionKeys.recoveryReason) {
case 'pending-recipient':
return 'Finishing security setup for older messages...';
case 'keys-unavailable':
return 'Setting up secure keys...';
case 'stale-key':
case 'rotation':
return 'Updating security for this device...';
default:
return 'Updating security...';
}
})();
// Message actions hook for deletion and reporting
const { showDeleteOptions, deleteForSelf } = useMessageActions({
@@ -321,6 +335,14 @@ export const ChatScreen: React.FC = () => {
onBack={() => router.push('/(app)/matches')}
/>
{recoveryMessage && (
<View style={styles.securityBanner}>
<Ionicons name="shield-checkmark-outline" size={16} color={ACCENT} />
<Text style={styles.securityBannerText}>{recoveryMessage}</Text>
<ActivityIndicator size="small" color={ACCENT} />
</View>
)}
<ChatMessages
messages={messages}
userId={user?.id}
@@ -563,6 +585,25 @@ const styles = StyleSheet.create({
height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
securityBanner: {
marginHorizontal: spacing.md,
marginTop: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: 'rgba(212, 175, 55, 0.12)',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.25)',
},
securityBannerText: {
flex: 1,
color: colors.textSecondary,
fontSize: 13,
fontWeight: '600',
},
secureStatus: {
alignSelf: 'flex-start',
flexDirection: 'row',
+106 -76
View File
@@ -8,12 +8,19 @@
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, repairStaleKey } from '../lib/encryption';
import {
decryptMediaFile,
cleanupDecryptedMedia,
MESSAGE_VERSION_E2EE,
repairStaleKey,
triggerAutoKeyRotation,
} 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';
import { useAuthStore } from '../store/authStore';
// Import shared cache from mediaCache to avoid circular dependency
import { decryptedMediaCache, clearDecryptedMediaCache } from '../lib/mediaCache';
@@ -86,6 +93,8 @@ export function useDecryptedMedia({
});
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
const beginEncryptionRecovery = useAuthStore((s) => s.beginEncryptionRecovery);
const endEncryptionRecovery = useAuthStore((s) => s.endEncryptionRecovery);
const isMounted = useRef(true);
const repairAttemptedByMessageId = useRef(new Set<string>());
@@ -216,26 +225,31 @@ export function useDecryptedMedia({
);
if (!isMe) {
setState({
uri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
beginEncryptionRecovery('pending-recipient');
try {
setState({
uri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
if (!repairAttemptedByMessageId.current.has(messageId)) {
repairAttemptedByMessageId.current.add(messageId);
await reencryptPendingMessages();
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);
} finally {
endEncryptionRecovery();
}
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) {
@@ -298,67 +312,80 @@ export function useDecryptedMedia({
// 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);
beginEncryptionRecovery('stale-key');
const autoRotationPromise = triggerAutoKeyRotation();
let didDecrypt = false;
try {
const repairResult = await repairStaleKey(messageId);
console.log(`[E2EE Media] Message ${messageId}: Attempting stale key repair...`);
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);
if (isMounted.current) {
setState({
uri: null,
isDecrypting: true,
error: null,
errorCode: undefined,
});
}
} catch (repairErr) {
console.error(`[E2EE Media] Message ${messageId}: Stale key repair threw:`, repairErr);
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,
});
}
didDecrypt = true;
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);
}
} finally {
await autoRotationPromise;
if (autoRotationPromise && !didDecrypt) {
retry();
}
endEncryptionRecovery();
}
}
@@ -385,6 +412,9 @@ export function useDecryptedMedia({
hasKeys,
keysLoading,
retryNonce,
beginEncryptionRecovery,
endEncryptionRecovery,
retry,
]);
useEffect(() => {
+146 -98
View File
@@ -12,6 +12,7 @@ import {
MESSAGE_VERSION_PLAINTEXT,
MESSAGE_VERSION_E2EE,
repairStaleKey,
triggerAutoKeyRotation,
} from '../lib/encryption';
import type {
DecryptedMessageState,
@@ -21,6 +22,7 @@ import type {
import { supabase } from '../lib/supabase';
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
import { useEncryptionKeys } from './useEncryptionKeys';
import { useAuthStore } from '../store/authStore';
const AUTO_RETRY_BASE_DELAY_MS = 3000;
const MAX_AUTO_RETRIES = 2;
@@ -67,7 +69,9 @@ export function useDecryptedMessage({
error: null,
});
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
const { privateKeyJwk, hasKeys, isLoading: keysLoading, ensureKeysUploaded } = useEncryptionKeys();
const beginEncryptionRecovery = useAuthStore((s) => s.beginEncryptionRecovery);
const endEncryptionRecovery = useAuthStore((s) => s.endEncryptionRecovery);
// Track per-message repair attempts to avoid tight retry loops.
const repairAttemptedByMessageId = useRef(new Set<string>());
@@ -215,99 +219,40 @@ export function useDecryptedMessage({
!repairAttemptedByMessageId.current.has(message.id);
if (shouldAttemptPendingRepair) {
setState({
content: null,
mediaUri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
if (!repairAttemptedByMessageId.current.has(message.id)) {
repairAttemptedByMessageId.current.add(message.id);
await reencryptPendingMessages();
}
// Fetch the latest keys_metadata and retry once locally.
const { data: refreshed } = await supabase
.from('messages')
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
.eq('id', message.id)
.maybeSingle();
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
const refreshedIv = refreshed?.encryption_iv ?? null;
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
try {
const decryptedContent = await decryptTextMessage(
refreshedEncryptedContent,
refreshedIv,
refreshedMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
true, // isRecipient
);
setState({
content: decryptedContent,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: undefined,
});
return;
} catch {
// Still pending (or repair failed); show the placeholder instead of a hard error.
}
}
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
return;
}
if (shouldAttemptStaleKeyRepair) {
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting stale key repair...`);
setState({
content: null,
mediaUri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_REPAIRING_STALE_KEY',
});
repairAttemptedByMessageId.current.add(message.id);
beginEncryptionRecovery('pending-recipient');
try {
const repairResult = await repairStaleKey(message.id);
setState({
content: null,
mediaUri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
if (repairResult.success) {
console.log(`[E2EE Decrypt] Message ${message.id}: Stale key repaired, retrying decryption...`);
if (!repairAttemptedByMessageId.current.has(message.id)) {
repairAttemptedByMessageId.current.add(message.id);
await reencryptPendingMessages();
}
// Fetch updated message and retry
const { data: refreshed } = await supabase
.from('messages')
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
.eq('id', message.id)
.maybeSingle();
// Fetch the latest keys_metadata and retry once locally.
const { data: refreshed } = await supabase
.from('messages')
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
.eq('id', message.id)
.maybeSingle();
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
const refreshedIv = refreshed?.encryption_iv ?? null;
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
const refreshedIv = refreshed?.encryption_iv ?? null;
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
try {
const decryptedContent = await decryptTextMessage(
refreshedEncryptedContent,
refreshedIv,
refreshedMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
!isMe, // isRecipient
true, // isRecipient
);
setState({
@@ -318,23 +263,122 @@ export function useDecryptedMessage({
errorCode: undefined,
});
return;
} catch {
// Still pending (or repair failed); show the placeholder instead of a hard error.
}
} else {
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair failed:`, repairResult.error);
}
} catch (repairErr) {
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair threw:`, repairErr);
}
// Repair failed or retry failed
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Waiting for message...'),
errorCode: 'E2EE_DECRYPT_FAILED',
});
return;
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
return;
} finally {
endEncryptionRecovery();
}
}
if (shouldAttemptStaleKeyRepair) {
beginEncryptionRecovery('stale-key');
let didDecrypt = false;
try {
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting stale key repair...`);
setState({
content: null,
mediaUri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_REPAIRING_STALE_KEY',
});
repairAttemptedByMessageId.current.add(message.id);
// CRITICAL: Ensure local keys are uploaded to database BEFORE repair.
// The repair edge function uses the database public key to re-wrap.
// If the database has a stale key, repair will "succeed" but decryption will fail.
console.log(`[E2EE Decrypt] Message ${message.id}: Ensuring keys are synced before repair...`);
const keysUploaded = await ensureKeysUploaded();
console.log(`[E2EE Decrypt] Message ${message.id}: Keys sync result: ${keysUploaded}`);
// Run auto key rotation to re-wrap recent messages BEFORE individual repair.
// This ensures handle-key-rotation uses the correct database key.
const autoRotationResult = await triggerAutoKeyRotation();
if (autoRotationResult) {
console.log(`[E2EE Decrypt] Message ${message.id}: Auto rotation completed: ${autoRotationResult.updated} messages updated`);
}
try {
const repairResult = await repairStaleKey(message.id);
if (repairResult.success) {
console.log(`[E2EE Decrypt] Message ${message.id}: Stale key repaired, retrying decryption...`);
// Fetch updated message and retry
const { data: refreshed } = await supabase
.from('messages')
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
.eq('id', message.id)
.maybeSingle();
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
const refreshedIv = refreshed?.encryption_iv ?? null;
// Log key metadata for debugging
console.log(`[E2EE Decrypt] Message ${message.id}: Refreshed metadata:`, {
hasSenderKey: !!refreshedMetadata?.sender_wrapped_key,
hasRecipientKey: !!refreshedMetadata?.recipient_wrapped_key,
adminKeyId: refreshedMetadata?.admin_key_id,
isRecipient: !isMe,
});
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
const decryptedContent = await decryptTextMessage(
refreshedEncryptedContent,
refreshedIv,
refreshedMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
!isMe, // isRecipient
);
setState({
content: decryptedContent,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: undefined,
});
didDecrypt = true;
return;
}
} else {
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair failed:`, repairResult.error);
}
} catch (repairErr) {
// Note: This catch includes decryption errors after successful repair
console.error(`[E2EE Decrypt] Message ${message.id}: Decryption retry failed after repair:`, repairErr);
}
// Repair failed or retry failed
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Waiting for message...'),
errorCode: 'E2EE_DECRYPT_FAILED',
});
return;
} finally {
if (!didDecrypt) {
// Schedule another retry after a delay - keys may sync in the background
retry();
}
endEncryptionRecovery();
}
}
setState({
@@ -359,6 +403,10 @@ export function useDecryptedMessage({
hasKeys,
keysLoading,
retryNonce,
beginEncryptionRecovery,
endEncryptionRecovery,
retry,
ensureKeysUploaded,
]);
useEffect(() => {
@@ -40,6 +40,8 @@ interface UseEncryptionKeysReturn {
initializeAndUploadKeys: () => Promise<{ wasRegenerated: boolean }>;
/** Refresh the key state from storage */
refresh: () => Promise<void>;
/** Ensure local keys are uploaded to database. Blocks until complete. */
ensureKeysUploaded: () => Promise<boolean>;
}
// Global promise to track in-flight key loading across all hook instances
@@ -274,6 +276,11 @@ export function useEncryptionKeys(): UseEncryptionKeysReturn {
// Upload if remote key is missing/invalid or doesn't match local.
// If we just regenerated the key, we MUST upload it regardless
if (publicKey && localValid && !sameKey) {
// Treat a mismatch as regeneration so we trigger key rotation later.
wasRegenerated = true;
}
if ((publicKey && localValid && !sameKey) || (publicKey && wasRegenerated)) {
console.log('[E2EE Global] Uploading public key to profile...');
@@ -323,6 +330,75 @@ export function useEncryptionKeys(): UseEncryptionKeysReturn {
await loadKeys();
}, [loadKeys]);
/**
* Ensure local keys are uploaded to database.
* Blocks until upload is complete or fails.
* This is critical for E2EE repair flows where the edge function needs
* the current public key to re-wrap message AES keys.
*/
const ensureKeysUploaded = useCallback(async (): Promise<boolean> => {
if (Platform.OS === 'web') {
console.log('[E2EE] ensureKeysUploaded: Web platform, skipping');
return false;
}
if (!user?.id) {
console.log('[E2EE] ensureKeysUploaded: No user ID, skipping');
return false;
}
try {
// Check if local key matches database key
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('public_key_jwk')
.eq('id', user.id)
.maybeSingle();
if (profileError) {
console.error('[E2EE] ensureKeysUploaded: Failed to fetch profile:', profileError);
// Fall through to initializeAndUploadKeys which will handle this
}
const remoteKey = (profile?.public_key_jwk ?? null) as RSAPublicKeyJWK | null;
const localKey = encryptionKeys.publicKeyJwk;
if (!localKey) {
// No local key, need to initialize
console.log('[E2EE] ensureKeysUploaded: No local key, initializing...');
await initializeAndUploadKeys();
return true;
}
// Compare keys (sanitize both for accurate comparison)
const sanitizedLocal = sanitizePublicKeyJwk(localKey);
const sanitizedRemote = remoteKey ? sanitizePublicKeyJwk(remoteKey) : null;
const keysMatch = sanitizedRemote &&
sanitizedLocal.n === sanitizedRemote.n &&
sanitizedLocal.e === sanitizedRemote.e;
if (!keysMatch) {
// Mismatch - upload local key
console.log('[E2EE] ensureKeysUploaded: Key mismatch detected, uploading local key...');
await initializeAndUploadKeys();
return true;
}
console.log('[E2EE] ensureKeysUploaded: Keys already synced');
return true;
} catch (error) {
console.error('[E2EE] ensureKeysUploaded: Error:', error);
// Try to initialize anyway as a fallback
try {
await initializeAndUploadKeys();
return true;
} catch {
return false;
}
}
}, [user?.id, encryptionKeys.publicKeyJwk, initializeAndUploadKeys]);
// Load keys on mount if not already loaded
useEffect(() => {
if (!hasLoadedRef.current && !isLoadingRef.current && !encryptionKeys.hasKeys) {
@@ -345,5 +421,6 @@ export function useEncryptionKeys(): UseEncryptionKeysReturn {
error: encryptionKeys.keysError,
initializeAndUploadKeys,
refresh,
ensureKeysUploaded,
};
}
+6
View File
@@ -8,6 +8,12 @@ export async function initAnalytics() {
return;
}
// Skip initialization in non-RN environments (e.g., during tsc/Node.js)
// AsyncStorage requires window to be defined
if (typeof window === "undefined" && typeof global !== "undefined" && !("__BUNDLE_START_TIME__" in global)) {
return;
}
const apiKey = process.env.EXPO_PUBLIC_POSTHOG_API_KEY;
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
+1 -1
View File
@@ -61,7 +61,7 @@ export {
} from './utils';
// Key Rotation
export { triggerKeyRotation } from './triggerKeyRotation';
export { triggerKeyRotation, triggerAutoKeyRotation } from './triggerKeyRotation';
// Stale Key Repair
export { repairStaleKey } from './repairStaleKey';
@@ -10,6 +10,8 @@
*/
import { supabase } from '../supabase';
import { reencryptPendingMessages } from './reencryptPendingMessages';
export interface KeyRotationResult {
success: boolean;
@@ -19,6 +21,11 @@ export interface KeyRotationResult {
}
let inFlight: Promise<KeyRotationResult | null> | null = null;
let autoRotationInFlight: Promise<KeyRotationResult | null> | null = null;
let lastAutoRotationAt = 0;
const AUTO_ROTATION_MIN_INTERVAL_MS = 60_000;
export async function triggerKeyRotation(): Promise<KeyRotationResult | null> {
// Prevent concurrent calls
@@ -70,3 +77,26 @@ export async function triggerKeyRotation(): Promise<KeyRotationResult | null> {
return inFlight;
}
export function triggerAutoKeyRotation(): Promise<KeyRotationResult | null> | null {
const now = Date.now();
if (autoRotationInFlight) return autoRotationInFlight;
if (now - lastAutoRotationAt < AUTO_ROTATION_MIN_INTERVAL_MS) {
return null;
}
lastAutoRotationAt = now;
autoRotationInFlight = (async () => {
console.log('[E2EE] Auto key rotation triggered by decryption failure.');
const result = await triggerKeyRotation();
await reencryptPendingMessages({ force: true });
return result;
})().finally(() => {
autoRotationInFlight = null;
});
return autoRotationInFlight;
}
+61
View File
@@ -0,0 +1,61 @@
import type { Ionicons } from "@expo/vector-icons";
type IoniconsName = keyof typeof Ionicons.glyphMap;
/**
* Maps emoji icons to Ionicons for a more professional, consistent appearance.
* This eliminates the "AI slop" feel of heavy emoji usage while maintaining
* visual differentiation between packs.
*/
export const PACK_ICON_MAP: Record<string, IoniconsName> = {
// Career & Professional
"💼": "briefcase-outline",
// Relationships & Connection
"🔗": "heart-half-outline",
"❤️": "heart-outline",
"💫": "sparkles-outline",
// Secrets & Mystery
"🤫": "eye-off-outline",
"🗝️": "key-outline",
// Romance & Dates
"🌹": "flower-outline",
"🍷": "wine-outline",
"✨": "sparkles-outline",
// Adventure & Travel
"✈️": "airplane-outline",
"🚗": "car-outline",
"🏡": "home-outline",
// Playful & Games
"🎭": "color-wand-outline",
"🎲": "dice-outline",
"🎁": "gift-outline",
// Intimacy & Physical
"🔄": "sync-outline",
"😈": "flash-outline",
"🔥": "flame-outline",
// Goals & Planning
"🎯": "flag-outline",
"☀️": "sunny-outline",
// Default fallbacks
"📦": "cube-outline",
"📁": "folder-outline",
};
export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline";
/**
* Converts an emoji icon string to an Ionicons name.
* Falls back to DEFAULT_PACK_ICON if no mapping exists.
*/
export function getPackIconName(emoji: string | null | undefined): IoniconsName {
if (!emoji) return DEFAULT_PACK_ICON;
return PACK_ICON_MAP[emoji] || DEFAULT_PACK_ICON;
}
+48
View File
@@ -10,6 +10,8 @@ import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from "../lib/encryption/types"
* Encryption key state - stored globally to ensure all components
* share the same key state and avoid race conditions during encryption.
*/
type EncryptionRecoveryReason = 'stale-key' | 'pending-recipient' | 'rotation' | 'keys-unavailable';
interface EncryptionKeyState {
privateKeyJwk: RSAPrivateKeyJWK | null;
publicKeyJwk: RSAPublicKeyJWK | null;
@@ -18,8 +20,14 @@ interface EncryptionKeyState {
keysError: Error | null;
/** Incremented when keys change - consumers can use this to trigger re-renders */
keysVersion: number;
/** Background recovery status for E2EE issues */
isRecovering: boolean;
recoveryReason: EncryptionRecoveryReason | null;
recoveryCount: number;
recoveryUpdatedAt: number | null;
}
interface AuthState {
user: Profile | null;
couple: Couple | null;
@@ -40,6 +48,9 @@ interface AuthState {
// Encryption key actions
setEncryptionKeys: (keys: Partial<EncryptionKeyState>) => void;
clearEncryptionKeys: () => void;
beginEncryptionRecovery: (reason: EncryptionRecoveryReason) => void;
endEncryptionRecovery: () => void;
}
// Import other stores lazily to avoid circular dependency issues
@@ -58,8 +69,13 @@ const initialEncryptionKeyState: EncryptionKeyState = {
isLoadingKeys: true,
keysError: null,
keysVersion: 0,
isRecovering: false,
recoveryReason: null,
recoveryCount: 0,
recoveryUpdatedAt: null,
};
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
couple: null,
@@ -248,6 +264,37 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}));
},
beginEncryptionRecovery: (reason) => {
set((state) => {
const nextCount = state.encryptionKeys.recoveryCount + 1;
return {
encryptionKeys: {
...state.encryptionKeys,
recoveryCount: nextCount,
isRecovering: true,
recoveryReason: reason,
recoveryUpdatedAt: Date.now(),
},
};
});
},
endEncryptionRecovery: () => {
set((state) => {
const nextCount = Math.max(0, state.encryptionKeys.recoveryCount - 1);
const isRecovering = nextCount > 0;
return {
encryptionKeys: {
...state.encryptionKeys,
recoveryCount: nextCount,
isRecovering,
recoveryReason: isRecovering ? state.encryptionKeys.recoveryReason : null,
recoveryUpdatedAt: Date.now(),
},
};
});
},
/**
* Clear encryption keys (called on sign out or when leaving couple)
*/
@@ -255,3 +302,4 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ encryptionKeys: initialEncryptionKeyState });
},
}));
+279
View File
@@ -0,0 +1,279 @@
# Pack Icons: Admin-First Approach
## Problem Statement
The current pack icon system uses emojis stored in the database, which creates a "AI slop" appearance. We've added a frontend mapping from emojis to Ionicons, but this is fragile:
- New packs require manual code changes to add mappings
- The mapping is hidden from admins
- It's a maintenance burden
## Solution: Admin-First Icon Selection
Allow admins to select Ionicons directly in the admin UI, storing the Ionicon name (e.g., `"briefcase-outline"`) in the database instead of emojis.
## Backwards Compatibility
**Important**: We must maintain backwards compatibility with the app currently in App Store review.
| App Version | DB has emoji "💼" | DB has "briefcase-outline" |
|-------------|-------------------|---------------------------|
| Old (in review) | Shows emoji | Shows literal text (broken) |
| New (with mapping) | Maps to Ionicon | Uses directly |
### Strategy
1. **Don't migrate existing data** - existing packs keep their emojis
2. **New packs use Ionicon names** - admin UI stores Ionicon names
3. **Mobile handles both formats** - detect format and handle appropriately
This ensures:
- Old app continues working with existing emoji packs
- New packs show default icon on old app (acceptable degradation)
- New app handles everything correctly
---
## Implementation Tasks
### Task 1: Create Ionicon Picker Component (Admin)
**File**: `apps/admin/src/components/ui/icon-picker.tsx` (new file)
Create a new component that displays a grid of available Ionicons for selection.
**Requirements**:
- Display commonly used Ionicons in a grid/dropdown
- Show icon preview with name
- Search/filter functionality (optional)
- Return the Ionicon name string (e.g., `"briefcase-outline"`)
**Suggested Icons to Include**:
```typescript
const AVAILABLE_ICONS = [
// Relationships & Love
'heart-outline', 'heart-half-outline', 'heart-circle-outline',
// Communication
'chatbubbles-outline', 'chatbox-outline', 'mail-outline',
// Romance & Dates
'flower-outline', 'wine-outline', 'restaurant-outline', 'cafe-outline',
// Adventure & Travel
'airplane-outline', 'car-outline', 'compass-outline', 'map-outline',
// Home & Family
'home-outline', 'people-outline', 'person-outline',
// Mystery & Secrets
'eye-off-outline', 'key-outline', 'lock-closed-outline',
// Fun & Games
'dice-outline', 'gift-outline', 'sparkles-outline', 'star-outline',
// Intimacy
'flame-outline', 'flash-outline', 'moon-outline', 'sunny-outline',
// Goals & Planning
'flag-outline', 'calendar-outline', 'checkbox-outline', 'trophy-outline',
// General
'layers-outline', 'cube-outline', 'folder-outline', 'bookmark-outline',
'bulb-outline', 'color-wand-outline', 'sync-outline', 'refresh-outline',
];
```
**Component Interface**:
```typescript
interface IconPickerProps {
value: string;
onChange: (iconName: string) => void;
}
```
---
### Task 2: Update Pack Form Dialog (Admin)
**File**: `apps/admin/src/components/content/PackFormDialog.tsx`
**Changes**:
1. Import new `IconPicker` component
2. Replace `EmojiPicker` with `IconPicker` for the icon field
**Current code (line ~101-104)**:
```tsx
<EmojiPicker
value={formData.icon}
onChange={(emoji) => setField('icon', emoji)}
/>
```
**New code**:
```tsx
<IconPicker
value={formData.icon}
onChange={(iconName) => setField('icon', iconName)}
/>
```
3. Update default icon in `initialFormData` (line ~41):
```typescript
// Change from '💕' to:
icon: 'heart-outline',
```
---
### Task 3: Update Category Form (Admin)
**File**: `apps/admin/src/pages/content/CategoriesPage.tsx`
Same changes as Task 2 - replace `EmojiPicker` with `IconPicker` for category icons.
---
### Task 4: Update AI Pack Generator (Admin)
**File**: `apps/admin/src/lib/ai/generators/packs.ts`
Update the AI prompt to suggest Ionicon names instead of emojis.
**Current (line ~147)**:
```typescript
- icon: A single descriptive emoji
```
**New**:
```typescript
- icon: An Ionicon name from this list: heart-outline, flame-outline, sparkles-outline, gift-outline, wine-outline, airplane-outline, home-outline, key-outline, flash-outline, sunny-outline, flower-outline, star-outline, dice-outline, compass-outline, bulb-outline
```
---
### Task 5: Update Mobile Icon Utility
**File**: `apps/mobile/src/lib/packIcons.ts`
Update `getPackIconName()` to handle both Ionicon names and legacy emojis.
**New implementation**:
```typescript
import type { Ionicons } from "@expo/vector-icons";
type IoniconsName = keyof typeof Ionicons.glyphMap;
// Legacy emoji mappings (for backwards compatibility with existing packs)
const EMOJI_TO_IONICON: Record<string, IoniconsName> = {
"💼": "briefcase-outline",
"🔗": "heart-half-outline",
"❤️": "heart-outline",
"💫": "sparkles-outline",
"🤫": "eye-off-outline",
"🗝️": "key-outline",
"🌹": "flower-outline",
"🍷": "wine-outline",
"✨": "sparkles-outline",
"✈️": "airplane-outline",
"🚗": "car-outline",
"🏡": "home-outline",
"🎭": "color-wand-outline",
"🎲": "dice-outline",
"🎁": "gift-outline",
"🔄": "sync-outline",
"😈": "flash-outline",
"🔥": "flame-outline",
"🎯": "flag-outline",
"☀️": "sunny-outline",
"📦": "cube-outline",
"📁": "folder-outline",
};
export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline";
// List of valid Ionicon names (subset we support)
const VALID_IONICONS = new Set([
'heart-outline', 'heart-half-outline', 'heart-circle-outline',
'chatbubbles-outline', 'chatbox-outline', 'mail-outline',
'flower-outline', 'wine-outline', 'restaurant-outline', 'cafe-outline',
'airplane-outline', 'car-outline', 'compass-outline', 'map-outline',
'home-outline', 'people-outline', 'person-outline',
'eye-off-outline', 'key-outline', 'lock-closed-outline',
'dice-outline', 'gift-outline', 'sparkles-outline', 'star-outline',
'flame-outline', 'flash-outline', 'moon-outline', 'sunny-outline',
'flag-outline', 'calendar-outline', 'checkbox-outline', 'trophy-outline',
'layers-outline', 'cube-outline', 'folder-outline', 'bookmark-outline',
'bulb-outline', 'color-wand-outline', 'sync-outline', 'refresh-outline',
'briefcase-outline',
]);
/**
* Converts an icon value to an Ionicons name.
* Handles both:
* - Ionicon names directly (new packs): "briefcase-outline" -> "briefcase-outline"
* - Legacy emojis (existing packs): "💼" -> "briefcase-outline"
*/
export function getPackIconName(icon: string | null | undefined): IoniconsName {
if (!icon) return DEFAULT_PACK_ICON;
// Check if it's already a valid Ionicon name
if (VALID_IONICONS.has(icon)) {
return icon as IoniconsName;
}
// Fall back to emoji mapping for legacy data
return EMOJI_TO_IONICON[icon] || DEFAULT_PACK_ICON;
}
```
---
### Task 6: Add Ionicons Package to Admin (if needed)
**File**: `apps/admin/package.json`
The admin app may need the Ionicons package to display icon previews.
```bash
cd apps/admin
npm install react-icons
```
Then use `IoIcons` from `react-icons/io5` which matches Ionicons naming.
Alternatively, create simple SVG components or use icon font directly.
---
## File Summary
| File | Action | Priority |
|------|--------|----------|
| `apps/admin/src/components/ui/icon-picker.tsx` | Create | High |
| `apps/admin/src/components/content/PackFormDialog.tsx` | Modify | High |
| `apps/admin/src/pages/content/CategoriesPage.tsx` | Modify | Medium |
| `apps/admin/src/lib/ai/generators/packs.ts` | Modify | Low |
| `apps/mobile/src/lib/packIcons.ts` | Modify | High |
| `apps/admin/package.json` | Modify (maybe) | Medium |
---
## Testing Checklist
- [ ] Create a new pack in admin with Ionicon - verify it saves correctly
- [ ] Edit an existing pack (with emoji) - verify emoji still displays in picker
- [ ] Mobile app displays new Ionicon packs correctly
- [ ] Mobile app displays legacy emoji packs correctly (via mapping)
- [ ] Old app version (if testable) shows default icon for new Ionicon packs
---
## Future Considerations
Once the new app version is widely deployed:
1. **Optional**: Run a database migration to convert remaining emojis to Ionicons
2. **Optional**: Remove emoji mapping code from mobile app
3. **Optional**: Add migration script admins can run per-pack
For now, the backwards-compatible approach means no database migrations and no breaking changes.
+427
View File
@@ -0,0 +1,427 @@
# Plan: Remove E2EE and Switch to Server-Side Security
## Overview
This document outlines the plan to remove end-to-end encryption (E2EE) from Sauci's chat system and rely on Supabase's built-in security instead.
### Current State
- Messages are encrypted client-side using AES-256-GCM with triple-wrapped RSA keys
- Each message has sender, recipient, and admin wrapped keys
- Complex key management: generation, storage, rotation, recovery
- ~2500+ lines of encryption code across 21 files
- Edge functions handle key rotation and pending message re-encryption
### Target State
- Messages stored as plaintext in Supabase (protected by RLS policies)
- TLS encryption in transit (Supabase default)
- Encryption at rest (Supabase infrastructure)
- No client-side key management
- Simplified chat code
### Why Remove E2EE?
1. **Complexity** - Triple-wrapped keys, device recovery, key rotation adds significant maintenance burden
2. **Overkill** - For a couples intimacy app, server-side security is sufficient
3. **User Experience** - E2EE adds latency, recovery flows, and potential failure modes
4. **Development Velocity** - Simpler code = faster iteration
### Security Model After Removal
- **In Transit**: TLS 1.3 (Supabase default)
- **At Rest**: Supabase encrypts data at rest
- **Access Control**: RLS policies restrict access to couple's own messages
- **Admin Access**: Admins can read messages (same as current E2EE with admin key)
---
## Migration Strategy for Existing Messages
Existing v2 (encrypted) messages need handling. Options:
### Option A: Decrypt and Convert (Recommended)
Run a one-time migration to decrypt all v2 messages using admin key and store as v1 plaintext.
- Pros: Clean slate, no legacy code needed
- Cons: Requires admin private key, one-time effort
### Option B: Leave v2 Messages As-Is
Keep encrypted messages in database but stop creating new ones. Old messages become unreadable.
- Pros: Simpler migration
- Cons: Users lose access to old messages
### Option C: Hybrid Display
Keep decryption code for reading old v2 messages, but send new messages as v1.
- Pros: No data loss
- Cons: Maintains complexity, defeats purpose of removal
**Recommendation**: Option A - decrypt existing messages during migration window.
---
## Tasks
### Phase 1: Database Migration (Estimated: 2-3 hours)
#### Task 1.1: Create Migration to Decrypt Existing v2 Messages
Create an edge function that:
1. Fetches all messages with `version = 2`
2. Decrypts each using admin private key
3. Updates to `version = 1`, moves decrypted content to `content` column
4. Clears `encrypted_content`, `encryption_iv`, `keys_metadata`
**Files to create:**
- `apps/supabase/functions/migrate-e2ee-to-plaintext/index.ts`
#### Task 1.2: Run Migration
Execute the migration function against both non-prod and prod databases.
#### Task 1.3: Verify Migration
Query to confirm no v2 messages remain:
```sql
SELECT COUNT(*) FROM messages WHERE version = 2;
```
---
### Phase 2: Remove Client-Side Encryption Code (Estimated: 4-6 hours)
#### Task 2.1: Remove Encryption Library
Delete the entire encryption directory:
- `apps/mobile/src/lib/encryption/` (14 files)
**Files to delete:**
- `adminKeys.ts`
- `constants.ts`
- `crypto.ts`
- `index.ts`
- `keyManager.ts`
- `mediaDecryption.ts`
- `mediaEncryption.ts`
- `messageDecryption.ts`
- `messageEncryption.ts`
- `reencryptPendingMessages.ts`
- `repairStaleKey.ts`
- `triggerKeyRotation.ts`
- `types.ts`
#### Task 2.2: Remove Encryption Hooks
Delete the E2EE-specific hooks:
**Files to delete:**
- `apps/mobile/src/hooks/useDecryptedMessage.ts`
- `apps/mobile/src/hooks/useDecryptedMedia.ts`
- `apps/mobile/src/hooks/useEncryptedSend.ts`
#### Task 2.3: Simplify useEncryptionKeys Hook
Either delete entirely or convert to a no-op stub that returns `{ hasKeys: true }` for compatibility.
**File to modify/delete:**
- `apps/mobile/src/hooks/useEncryptionKeys.ts`
#### Task 2.4: Remove EncryptionKeyInitializer Component
Delete the component that initializes E2EE keys on app startup.
**File to delete:**
- `apps/mobile/src/components/EncryptionKeyInitializer.tsx`
#### Task 2.5: Remove from App Layout
Remove `EncryptionKeyInitializer` from the app layout.
**File to modify:**
- `apps/mobile/app/(app)/_layout.tsx`
---
### Phase 3: Simplify Chat Components (Estimated: 3-4 hours)
#### Task 3.1: Simplify ChatScreen.tsx
Remove:
- `useEncryptedSend` hook usage
- `encryptionKeys` from store
- `secureReady` checks
- Recovery UI (`encryptionKeys.isRecovering`)
- Encryption readiness blocking
Change message sending to use plaintext directly.
**File to modify:**
- `apps/mobile/src/features/chat/ChatScreen.tsx`
#### Task 3.2: Simplify MessageContent.tsx
Remove:
- `useDecryptedMessage` hook usage
- `useDecryptedMedia` hook usage
- Decryption loading states
- Decryption error states and retry buttons
Display `message.content` directly.
**File to modify:**
- `apps/mobile/src/features/chat/MessageContent.tsx`
#### Task 3.3: Simplify Media Upload
Remove encryption from media upload flow.
**File to modify:**
- `apps/mobile/src/hooks/useMediaUpload.ts`
Changes:
- Remove `encryptMediaFile` call
- Upload files directly without `.enc` extension
- Remove E2EE readiness checks
#### Task 3.4: Simplify InputBar (if applicable)
Remove any "security not ready" blocking logic.
**File to modify:**
- `apps/mobile/src/features/chat/InputBar.tsx`
---
### Phase 4: Clean Up State Management (Estimated: 1-2 hours)
#### Task 4.1: Remove encryptionKeys from Auth Store
Remove the entire `encryptionKeys` state object and related actions.
**File to modify:**
- `apps/mobile/src/store/authStore.ts`
Remove:
- `EncryptionKeyState` interface
- `encryptionKeys` state
- `setEncryptionKeys` action
- `beginEncryptionRecovery` / `endEncryptionRecovery` actions
- Reset logic in logout
---
### Phase 5: Remove Edge Functions (Estimated: 1 hour)
#### Task 5.1: Delete E2EE Edge Functions
Remove the edge functions that handle key rotation and re-encryption.
**Files to delete:**
- `apps/supabase/functions/handle-key-rotation/`
- `apps/supabase/functions/reencrypt-pending-messages/`
#### Task 5.2: Remove from Supabase Dashboard
If deployed, remove these functions from Supabase dashboard or let them remain (they won't be called).
---
### Phase 6: Database Cleanup (Optional) (Estimated: 1 hour)
#### Task 6.1: Remove E2EE Columns (Optional)
Create migration to drop unused columns. Can be deferred.
```sql
-- Optional cleanup migration
ALTER TABLE messages
DROP COLUMN IF EXISTS encrypted_content,
DROP COLUMN IF EXISTS encryption_iv,
DROP COLUMN IF EXISTS keys_metadata;
ALTER TABLE messages
DROP CONSTRAINT IF EXISTS messages_e2ee_fields_check;
ALTER TABLE profiles
DROP COLUMN IF EXISTS public_key_jwk;
DROP TABLE IF EXISTS master_keys;
```
**Note**: Can leave columns in place - they'll just be NULL for new messages.
#### Task 6.2: Update Message Version Default
Ensure new messages use version 1 (or remove version concept entirely).
---
### Phase 7: Cleanup and Testing (Estimated: 2-3 hours)
#### Task 7.1: Remove Unused Dependencies
Check if `react-native-quick-crypto` can be removed (may be needed for other crypto).
**File to modify:**
- `apps/mobile/package.json`
#### Task 7.2: Update CLAUDE.md
Remove E2EE documentation section.
**File to modify:**
- `CLAUDE.md`
#### Task 7.3: Delete E2EE Documentation
Remove the E2EE implementation docs.
**File to delete:**
- `docs/e2ee-implementation.md`
#### Task 7.4: Update iOS Permission Strings
Remove "encrypted" from iOS permission descriptions. These strings mention encryption:
**File to modify:**
- `apps/mobile/app.json`
**Current strings (lines 26-27):**
```json
"NSCameraUsageDescription": "Sauci uses your camera to take photos and videos that you can share privately with your partner in encrypted chat messages.",
"NSPhotoLibraryUsageDescription": "Sauci uses your photo library so you can select photos and videos to share privately with your partner in encrypted chat messages.",
```
**Updated strings:**
```json
"NSCameraUsageDescription": "Sauci uses your camera to take photos and videos that you can share privately with your partner.",
"NSPhotoLibraryUsageDescription": "Sauci uses your photo library so you can select photos and videos to share privately with your partner.",
```
After updating `app.json`, run `npx expo prebuild --clean` to regenerate `ios/Sauci/Info.plist`.
#### Task 7.5: Full Testing
Test all chat functionality:
- [ ] Send text message
- [ ] Receive text message
- [ ] Send image
- [ ] Receive image
- [ ] Send video
- [ ] Receive video
- [ ] View old messages (post-migration)
- [ ] New user signup and chat
- [ ] Device change / reinstall
---
## File Summary
### Files to Delete (19 files)
```text
apps/mobile/src/lib/encryption/adminKeys.ts
apps/mobile/src/lib/encryption/constants.ts
apps/mobile/src/lib/encryption/crypto.ts
apps/mobile/src/lib/encryption/index.ts
apps/mobile/src/lib/encryption/keyManager.ts
apps/mobile/src/lib/encryption/mediaDecryption.ts
apps/mobile/src/lib/encryption/mediaEncryption.ts
apps/mobile/src/lib/encryption/messageDecryption.ts
apps/mobile/src/lib/encryption/messageEncryption.ts
apps/mobile/src/lib/encryption/reencryptPendingMessages.ts
apps/mobile/src/lib/encryption/repairStaleKey.ts
apps/mobile/src/lib/encryption/triggerKeyRotation.ts
apps/mobile/src/lib/encryption/types.ts
apps/mobile/src/hooks/useDecryptedMessage.ts
apps/mobile/src/hooks/useDecryptedMedia.ts
apps/mobile/src/hooks/useEncryptedSend.ts
apps/mobile/src/hooks/useEncryptionKeys.ts
apps/mobile/src/components/EncryptionKeyInitializer.tsx
docs/e2ee-implementation.md
```
### Files to Modify (8 files)
```text
apps/mobile/src/features/chat/ChatScreen.tsx
apps/mobile/src/features/chat/MessageContent.tsx
apps/mobile/src/features/chat/InputBar.tsx
apps/mobile/src/hooks/useMediaUpload.ts
apps/mobile/src/store/authStore.ts
apps/mobile/app/(app)/_layout.tsx
apps/mobile/app.json
CLAUDE.md
```
### Edge Functions to Delete (2 functions)
```text
apps/supabase/functions/handle-key-rotation/
apps/supabase/functions/reencrypt-pending-messages/
```
---
## Estimated Total Effort
| Phase | Estimated Time |
| ------------------------------- | ---------------- |
| Phase 1: Database Migration | 2-3 hours |
| Phase 2: Remove Encryption Code | 4-6 hours |
| Phase 3: Simplify Chat Components | 3-4 hours |
| Phase 4: Clean Up State | 1-2 hours |
| Phase 5: Remove Edge Functions | 1 hour |
| Phase 6: Database Cleanup | 1 hour (optional)|
| Phase 7: Testing | 2-3 hours |
| **Total** | **14-20 hours** |
---
## Rollback Plan
If issues arise after deployment:
1. **Messages are preserved** - Content column still has plaintext for v1, migration preserves decrypted content
2. **Re-enable E2EE** - Git revert the client changes
3. **No data loss** - The migration only adds data (decrypts), doesn't delete
---
## Open Questions
1. **Timeline**: When to execute the migration? During maintenance window?
2. **Notification**: Should users be notified about the security model change?
3. **Privacy Policy**: Does the privacy policy mention E2EE? May need update.
4. **App Store**: Any App Store claims about E2EE that need updating?