mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
953bd7c03d
commit
43fa7033dc
14 files changed
+1075
-23
No files matched your search
@@ -18,6 +18,7 @@ import { GradientBackground } from "../../../src/components/ui";
|
|||||||
import { Paywall } from "../../../src/components/paywall";
|
import { Paywall } from "../../../src/components/paywall";
|
||||||
import { PackTeaser } from "../../../src/components/PackTeaser";
|
import { PackTeaser } from "../../../src/components/PackTeaser";
|
||||||
import { colors, spacing, radius, typography } from "../../../src/theme";
|
import { colors, spacing, radius, typography } from "../../../src/theme";
|
||||||
|
import { getPackIconName } from "../../../src/lib/packIcons";
|
||||||
import type { QuestionPack, Category } from "../../../src/types";
|
import type { QuestionPack, Category } from "../../../src/types";
|
||||||
|
|
||||||
const HEADER_MAX_HEIGHT = 180;
|
const HEADER_MAX_HEIGHT = 180;
|
||||||
@@ -72,7 +73,11 @@ function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPre
|
|||||||
<Pressable onPress={onPress} style={styles.packRow}>
|
<Pressable onPress={onPress} style={styles.packRow}>
|
||||||
<View style={styles.packRowMain}>
|
<View style={styles.packRowMain}>
|
||||||
<View style={[styles.packIcon, isEnabled && styles.packIconEnabled]}>
|
<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>
|
||||||
|
|
||||||
<View style={styles.packContent}>
|
<View style={styles.packContent}>
|
||||||
@@ -206,9 +211,11 @@ function CategorySection({
|
|||||||
<View style={styles.categorySection}>
|
<View style={styles.categorySection}>
|
||||||
<Pressable style={styles.categoryHeader} onPress={handleToggleCollapse}>
|
<Pressable style={styles.categoryHeader} onPress={handleToggleCollapse}>
|
||||||
<View style={styles.categoryHeaderLeft}>
|
<View style={styles.categoryHeaderLeft}>
|
||||||
{category.icon && (
|
<Ionicons
|
||||||
<Text style={styles.categoryIcon}>{category.icon}</Text>
|
name={getPackIconName(category.icon)}
|
||||||
)}
|
size={16}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
/>
|
||||||
<Text style={styles.categoryName}>{category.name}</Text>
|
<Text style={styles.categoryName}>{category.name}</Text>
|
||||||
{enabledCount > 0 && (
|
{enabledCount > 0 && (
|
||||||
<View style={styles.categoryBadge}>
|
<View style={styles.categoryBadge}>
|
||||||
@@ -616,9 +623,6 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
categoryIcon: {
|
|
||||||
fontSize: 18,
|
|
||||||
},
|
|
||||||
categoryName: {
|
categoryName: {
|
||||||
...typography.headline,
|
...typography.headline,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
@@ -672,9 +676,6 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: "rgba(233, 69, 96, 0.1)",
|
backgroundColor: "rgba(233, 69, 96, 0.1)",
|
||||||
borderColor: "rgba(233, 69, 96, 0.2)",
|
borderColor: "rgba(233, 69, 96, 0.2)",
|
||||||
},
|
},
|
||||||
packIconText: {
|
|
||||||
fontSize: 20,
|
|
||||||
},
|
|
||||||
packContent: {
|
packContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||||
import { colors, gradients, spacing, radius, typography } from "../../src/theme";
|
import { colors, gradients, spacing, radius, typography } from "../../src/theme";
|
||||||
|
import { getPackIconName } from "../../src/lib/packIcons";
|
||||||
import { Question } from "../../src/types";
|
import { Question } from "../../src/types";
|
||||||
|
|
||||||
type TooltipType = 'initiator' | 'coupleType' | null;
|
type TooltipType = 'initiator' | 'coupleType' | null;
|
||||||
@@ -259,7 +260,11 @@ export default function PackDetailsScreen() {
|
|||||||
style={StyleSheet.absoluteFill}
|
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>
|
</View>
|
||||||
{/* Crown for premium */}
|
{/* Crown for premium */}
|
||||||
{pack?.is_premium && (
|
{pack?.is_premium && (
|
||||||
@@ -575,9 +580,6 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
},
|
},
|
||||||
emoji: {
|
|
||||||
fontSize: 36,
|
|
||||||
},
|
|
||||||
title: {
|
title: {
|
||||||
...typography.largeTitle,
|
...typography.largeTitle,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import Animated, {
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { supabase } from "../lib/supabase";
|
import { supabase } from "../lib/supabase";
|
||||||
|
import { getPackIconName } from "../lib/packIcons";
|
||||||
import { colors, gradients, spacing, radius, typography } from "../theme";
|
import { colors, gradients, spacing, radius, typography } from "../theme";
|
||||||
|
|
||||||
interface PackTeaserProps {
|
interface PackTeaserProps {
|
||||||
@@ -159,7 +160,11 @@ export function PackTeaser({
|
|||||||
colors={['rgba(212, 175, 55, 0.15)', 'rgba(184, 134, 11, 0.1)']}
|
colors={['rgba(212, 175, 55, 0.15)', 'rgba(184, 134, 11, 0.1)']}
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.packEmoji}>{packIcon || "📦"}</Text>
|
<Ionicons
|
||||||
|
name={getPackIconName(packIcon)}
|
||||||
|
size={36}
|
||||||
|
color={colors.premium.gold}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
{/* Crown badge */}
|
{/* Crown badge */}
|
||||||
<View style={styles.crownBadge}>
|
<View style={styles.crownBadge}>
|
||||||
@@ -370,9 +375,6 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
},
|
},
|
||||||
packEmoji: {
|
|
||||||
fontSize: 40,
|
|
||||||
},
|
|
||||||
title: {
|
title: {
|
||||||
...typography.title1,
|
...typography.title1,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
const matchId = id as string;
|
const matchId = id as string;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { user, partner } = useAuthStore();
|
const { user, partner, encryptionKeys } = useAuthStore();
|
||||||
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
|
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
|
||||||
const [match, setMatch] = useState<any>(null);
|
const [match, setMatch] = useState<any>(null);
|
||||||
const [inputText, setInputText] = useState("");
|
const [inputText, setInputText] = useState("");
|
||||||
@@ -77,6 +77,20 @@ export const ChatScreen: React.FC = () => {
|
|||||||
// E2EE encryption hook for sending messages
|
// E2EE encryption hook for sending messages
|
||||||
const { encryptMessage, isE2EEAvailable, isAdminKeyReady, isEncrypting } = useEncryptedSend();
|
const { encryptMessage, isE2EEAvailable, isAdminKeyReady, isEncrypting } = useEncryptedSend();
|
||||||
const secureReady = isE2EEAvailable && isAdminKeyReady;
|
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
|
// Message actions hook for deletion and reporting
|
||||||
const { showDeleteOptions, deleteForSelf } = useMessageActions({
|
const { showDeleteOptions, deleteForSelf } = useMessageActions({
|
||||||
@@ -321,6 +335,14 @@ export const ChatScreen: React.FC = () => {
|
|||||||
onBack={() => router.push('/(app)/matches')}
|
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
|
<ChatMessages
|
||||||
messages={messages}
|
messages={messages}
|
||||||
userId={user?.id}
|
userId={user?.id}
|
||||||
@@ -563,6 +585,25 @@ const styles = StyleSheet.create({
|
|||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: `${ACCENT_RGBA}0.15)`,
|
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: {
|
secureStatus: {
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -8,12 +8,19 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import * as FileSystem from 'expo-file-system';
|
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 type { KeysMetadata, RSAPrivateKeyJWK } from '../lib/encryption';
|
||||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||||
import { getCachedSignedUrl, getStoragePath } from '../lib/imageCache';
|
import { getCachedSignedUrl, getStoragePath } from '../lib/imageCache';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
// Import shared cache from mediaCache to avoid circular dependency
|
// Import shared cache from mediaCache to avoid circular dependency
|
||||||
import { decryptedMediaCache, clearDecryptedMediaCache } from '../lib/mediaCache';
|
import { decryptedMediaCache, clearDecryptedMediaCache } from '../lib/mediaCache';
|
||||||
|
|
||||||
@@ -86,6 +93,8 @@ export function useDecryptedMedia({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
||||||
|
const beginEncryptionRecovery = useAuthStore((s) => s.beginEncryptionRecovery);
|
||||||
|
const endEncryptionRecovery = useAuthStore((s) => s.endEncryptionRecovery);
|
||||||
const isMounted = useRef(true);
|
const isMounted = useRef(true);
|
||||||
|
|
||||||
const repairAttemptedByMessageId = useRef(new Set<string>());
|
const repairAttemptedByMessageId = useRef(new Set<string>());
|
||||||
@@ -216,6 +225,8 @@ export function useDecryptedMedia({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isMe) {
|
if (!isMe) {
|
||||||
|
beginEncryptionRecovery('pending-recipient');
|
||||||
|
try {
|
||||||
setState({
|
setState({
|
||||||
uri: null,
|
uri: null,
|
||||||
isDecrypting: true,
|
isDecrypting: true,
|
||||||
@@ -236,6 +247,9 @@ export function useDecryptedMedia({
|
|||||||
|
|
||||||
effectiveKeysMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
effectiveKeysMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||||
wrappedKey = getWrappedKey(effectiveKeysMetadata);
|
wrappedKey = getWrappedKey(effectiveKeysMetadata);
|
||||||
|
} finally {
|
||||||
|
endEncryptionRecovery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!wrappedKey) {
|
if (!wrappedKey) {
|
||||||
@@ -298,6 +312,11 @@ export function useDecryptedMedia({
|
|||||||
|
|
||||||
// Attempt stale key repair if not already attempted
|
// Attempt stale key repair if not already attempted
|
||||||
if (isStaleKeyFailure && !repairAttemptedByMessageId.current.has(messageId)) {
|
if (isStaleKeyFailure && !repairAttemptedByMessageId.current.has(messageId)) {
|
||||||
|
beginEncryptionRecovery('stale-key');
|
||||||
|
const autoRotationPromise = triggerAutoKeyRotation();
|
||||||
|
let didDecrypt = false;
|
||||||
|
|
||||||
|
try {
|
||||||
console.log(`[E2EE Media] Message ${messageId}: Attempting stale key repair...`);
|
console.log(`[E2EE Media] Message ${messageId}: Attempting stale key repair...`);
|
||||||
|
|
||||||
if (isMounted.current) {
|
if (isMounted.current) {
|
||||||
@@ -351,6 +370,7 @@ export function useDecryptedMedia({
|
|||||||
errorCode: undefined,
|
errorCode: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
didDecrypt = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,6 +380,13 @@ export function useDecryptedMedia({
|
|||||||
} catch (repairErr) {
|
} catch (repairErr) {
|
||||||
console.error(`[E2EE Media] Message ${messageId}: Stale key repair threw:`, repairErr);
|
console.error(`[E2EE Media] Message ${messageId}: Stale key repair threw:`, repairErr);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
await autoRotationPromise;
|
||||||
|
if (autoRotationPromise && !didDecrypt) {
|
||||||
|
retry();
|
||||||
|
}
|
||||||
|
endEncryptionRecovery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMounted.current) {
|
if (isMounted.current) {
|
||||||
@@ -385,6 +412,9 @@ export function useDecryptedMedia({
|
|||||||
hasKeys,
|
hasKeys,
|
||||||
keysLoading,
|
keysLoading,
|
||||||
retryNonce,
|
retryNonce,
|
||||||
|
beginEncryptionRecovery,
|
||||||
|
endEncryptionRecovery,
|
||||||
|
retry,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
MESSAGE_VERSION_PLAINTEXT,
|
MESSAGE_VERSION_PLAINTEXT,
|
||||||
MESSAGE_VERSION_E2EE,
|
MESSAGE_VERSION_E2EE,
|
||||||
repairStaleKey,
|
repairStaleKey,
|
||||||
|
triggerAutoKeyRotation,
|
||||||
} from '../lib/encryption';
|
} from '../lib/encryption';
|
||||||
import type {
|
import type {
|
||||||
DecryptedMessageState,
|
DecryptedMessageState,
|
||||||
@@ -21,6 +22,7 @@ import type {
|
|||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
||||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
const AUTO_RETRY_BASE_DELAY_MS = 3000;
|
const AUTO_RETRY_BASE_DELAY_MS = 3000;
|
||||||
const MAX_AUTO_RETRIES = 2;
|
const MAX_AUTO_RETRIES = 2;
|
||||||
@@ -67,7 +69,9 @@ export function useDecryptedMessage({
|
|||||||
error: null,
|
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.
|
// Track per-message repair attempts to avoid tight retry loops.
|
||||||
const repairAttemptedByMessageId = useRef(new Set<string>());
|
const repairAttemptedByMessageId = useRef(new Set<string>());
|
||||||
@@ -215,6 +219,8 @@ export function useDecryptedMessage({
|
|||||||
!repairAttemptedByMessageId.current.has(message.id);
|
!repairAttemptedByMessageId.current.has(message.id);
|
||||||
|
|
||||||
if (shouldAttemptPendingRepair) {
|
if (shouldAttemptPendingRepair) {
|
||||||
|
beginEncryptionRecovery('pending-recipient');
|
||||||
|
try {
|
||||||
setState({
|
setState({
|
||||||
content: null,
|
content: null,
|
||||||
mediaUri: null,
|
mediaUri: null,
|
||||||
@@ -270,9 +276,16 @@ export function useDecryptedMessage({
|
|||||||
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
} finally {
|
||||||
|
endEncryptionRecovery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldAttemptStaleKeyRepair) {
|
if (shouldAttemptStaleKeyRepair) {
|
||||||
|
beginEncryptionRecovery('stale-key');
|
||||||
|
let didDecrypt = false;
|
||||||
|
|
||||||
|
try {
|
||||||
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting stale key repair...`);
|
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting stale key repair...`);
|
||||||
setState({
|
setState({
|
||||||
content: null,
|
content: null,
|
||||||
@@ -284,6 +297,20 @@ export function useDecryptedMessage({
|
|||||||
|
|
||||||
repairAttemptedByMessageId.current.add(message.id);
|
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 {
|
try {
|
||||||
const repairResult = await repairStaleKey(message.id);
|
const repairResult = await repairStaleKey(message.id);
|
||||||
|
|
||||||
@@ -301,6 +328,14 @@ export function useDecryptedMessage({
|
|||||||
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
|
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
|
||||||
const refreshedIv = refreshed?.encryption_iv ?? 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) {
|
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
|
||||||
const decryptedContent = await decryptTextMessage(
|
const decryptedContent = await decryptTextMessage(
|
||||||
refreshedEncryptedContent,
|
refreshedEncryptedContent,
|
||||||
@@ -317,13 +352,15 @@ export function useDecryptedMessage({
|
|||||||
error: null,
|
error: null,
|
||||||
errorCode: undefined,
|
errorCode: undefined,
|
||||||
});
|
});
|
||||||
|
didDecrypt = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair failed:`, repairResult.error);
|
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair failed:`, repairResult.error);
|
||||||
}
|
}
|
||||||
} catch (repairErr) {
|
} catch (repairErr) {
|
||||||
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair threw:`, 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
|
// Repair failed or retry failed
|
||||||
@@ -335,6 +372,13 @@ export function useDecryptedMessage({
|
|||||||
errorCode: 'E2EE_DECRYPT_FAILED',
|
errorCode: 'E2EE_DECRYPT_FAILED',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
} finally {
|
||||||
|
if (!didDecrypt) {
|
||||||
|
// Schedule another retry after a delay - keys may sync in the background
|
||||||
|
retry();
|
||||||
|
}
|
||||||
|
endEncryptionRecovery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState({
|
setState({
|
||||||
@@ -359,6 +403,10 @@ export function useDecryptedMessage({
|
|||||||
hasKeys,
|
hasKeys,
|
||||||
keysLoading,
|
keysLoading,
|
||||||
retryNonce,
|
retryNonce,
|
||||||
|
beginEncryptionRecovery,
|
||||||
|
endEncryptionRecovery,
|
||||||
|
retry,
|
||||||
|
ensureKeysUploaded,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ interface UseEncryptionKeysReturn {
|
|||||||
initializeAndUploadKeys: () => Promise<{ wasRegenerated: boolean }>;
|
initializeAndUploadKeys: () => Promise<{ wasRegenerated: boolean }>;
|
||||||
/** Refresh the key state from storage */
|
/** Refresh the key state from storage */
|
||||||
refresh: () => Promise<void>;
|
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
|
// 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.
|
// Upload if remote key is missing/invalid or doesn't match local.
|
||||||
// If we just regenerated the key, we MUST upload it regardless
|
// 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)) {
|
if ((publicKey && localValid && !sameKey) || (publicKey && wasRegenerated)) {
|
||||||
console.log('[E2EE Global] Uploading public key to profile...');
|
console.log('[E2EE Global] Uploading public key to profile...');
|
||||||
|
|
||||||
@@ -323,6 +330,75 @@ export function useEncryptionKeys(): UseEncryptionKeysReturn {
|
|||||||
await loadKeys();
|
await loadKeys();
|
||||||
}, [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
|
// Load keys on mount if not already loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasLoadedRef.current && !isLoadingRef.current && !encryptionKeys.hasKeys) {
|
if (!hasLoadedRef.current && !isLoadingRef.current && !encryptionKeys.hasKeys) {
|
||||||
@@ -345,5 +421,6 @@ export function useEncryptionKeys(): UseEncryptionKeysReturn {
|
|||||||
error: encryptionKeys.keysError,
|
error: encryptionKeys.keysError,
|
||||||
initializeAndUploadKeys,
|
initializeAndUploadKeys,
|
||||||
refresh,
|
refresh,
|
||||||
|
ensureKeysUploaded,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,12 @@ export async function initAnalytics() {
|
|||||||
return;
|
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 apiKey = process.env.EXPO_PUBLIC_POSTHOG_API_KEY;
|
||||||
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
|
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export {
|
|||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
// Key Rotation
|
// Key Rotation
|
||||||
export { triggerKeyRotation } from './triggerKeyRotation';
|
export { triggerKeyRotation, triggerAutoKeyRotation } from './triggerKeyRotation';
|
||||||
|
|
||||||
// Stale Key Repair
|
// Stale Key Repair
|
||||||
export { repairStaleKey } from './repairStaleKey';
|
export { repairStaleKey } from './repairStaleKey';
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { supabase } from '../supabase';
|
import { supabase } from '../supabase';
|
||||||
|
import { reencryptPendingMessages } from './reencryptPendingMessages';
|
||||||
|
|
||||||
|
|
||||||
export interface KeyRotationResult {
|
export interface KeyRotationResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -19,6 +21,11 @@ export interface KeyRotationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let inFlight: Promise<KeyRotationResult | null> | null = null;
|
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> {
|
export async function triggerKeyRotation(): Promise<KeyRotationResult | null> {
|
||||||
// Prevent concurrent calls
|
// Prevent concurrent calls
|
||||||
@@ -70,3 +77,26 @@ export async function triggerKeyRotation(): Promise<KeyRotationResult | null> {
|
|||||||
|
|
||||||
return inFlight;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from "../lib/encryption/types"
|
|||||||
* Encryption key state - stored globally to ensure all components
|
* Encryption key state - stored globally to ensure all components
|
||||||
* share the same key state and avoid race conditions during encryption.
|
* share the same key state and avoid race conditions during encryption.
|
||||||
*/
|
*/
|
||||||
|
type EncryptionRecoveryReason = 'stale-key' | 'pending-recipient' | 'rotation' | 'keys-unavailable';
|
||||||
|
|
||||||
interface EncryptionKeyState {
|
interface EncryptionKeyState {
|
||||||
privateKeyJwk: RSAPrivateKeyJWK | null;
|
privateKeyJwk: RSAPrivateKeyJWK | null;
|
||||||
publicKeyJwk: RSAPublicKeyJWK | null;
|
publicKeyJwk: RSAPublicKeyJWK | null;
|
||||||
@@ -18,8 +20,14 @@ interface EncryptionKeyState {
|
|||||||
keysError: Error | null;
|
keysError: Error | null;
|
||||||
/** Incremented when keys change - consumers can use this to trigger re-renders */
|
/** Incremented when keys change - consumers can use this to trigger re-renders */
|
||||||
keysVersion: number;
|
keysVersion: number;
|
||||||
|
/** Background recovery status for E2EE issues */
|
||||||
|
isRecovering: boolean;
|
||||||
|
recoveryReason: EncryptionRecoveryReason | null;
|
||||||
|
recoveryCount: number;
|
||||||
|
recoveryUpdatedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: Profile | null;
|
user: Profile | null;
|
||||||
couple: Couple | null;
|
couple: Couple | null;
|
||||||
@@ -40,6 +48,9 @@ interface AuthState {
|
|||||||
// Encryption key actions
|
// Encryption key actions
|
||||||
setEncryptionKeys: (keys: Partial<EncryptionKeyState>) => void;
|
setEncryptionKeys: (keys: Partial<EncryptionKeyState>) => void;
|
||||||
clearEncryptionKeys: () => void;
|
clearEncryptionKeys: () => void;
|
||||||
|
beginEncryptionRecovery: (reason: EncryptionRecoveryReason) => void;
|
||||||
|
endEncryptionRecovery: () => void;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import other stores lazily to avoid circular dependency issues
|
// Import other stores lazily to avoid circular dependency issues
|
||||||
@@ -58,8 +69,13 @@ const initialEncryptionKeyState: EncryptionKeyState = {
|
|||||||
isLoadingKeys: true,
|
isLoadingKeys: true,
|
||||||
keysError: null,
|
keysError: null,
|
||||||
keysVersion: 0,
|
keysVersion: 0,
|
||||||
|
isRecovering: false,
|
||||||
|
recoveryReason: null,
|
||||||
|
recoveryCount: 0,
|
||||||
|
recoveryUpdatedAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
couple: 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)
|
* 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 });
|
set({ encryptionKeys: initialEncryptionKeyState });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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?
|
||||||
Reference in new issue
Block a user