mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10: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 { 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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,6 +225,8 @@ export function useDecryptedMedia({
|
||||
);
|
||||
|
||||
if (!isMe) {
|
||||
beginEncryptionRecovery('pending-recipient');
|
||||
try {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: true,
|
||||
@@ -236,6 +247,9 @@ export function useDecryptedMedia({
|
||||
|
||||
effectiveKeysMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
wrappedKey = getWrappedKey(effectiveKeysMetadata);
|
||||
} finally {
|
||||
endEncryptionRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
if (!wrappedKey) {
|
||||
@@ -298,6 +312,11 @@ export function useDecryptedMedia({
|
||||
|
||||
// Attempt stale key repair if not already attempted
|
||||
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...`);
|
||||
|
||||
if (isMounted.current) {
|
||||
@@ -351,6 +370,7 @@ export function useDecryptedMedia({
|
||||
errorCode: undefined,
|
||||
});
|
||||
}
|
||||
didDecrypt = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -360,6 +380,13 @@ export function useDecryptedMedia({
|
||||
} catch (repairErr) {
|
||||
console.error(`[E2EE Media] Message ${messageId}: Stale key repair threw:`, repairErr);
|
||||
}
|
||||
} finally {
|
||||
await autoRotationPromise;
|
||||
if (autoRotationPromise && !didDecrypt) {
|
||||
retry();
|
||||
}
|
||||
endEncryptionRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
if (isMounted.current) {
|
||||
@@ -385,6 +412,9 @@ export function useDecryptedMedia({
|
||||
hasKeys,
|
||||
keysLoading,
|
||||
retryNonce,
|
||||
beginEncryptionRecovery,
|
||||
endEncryptionRecovery,
|
||||
retry,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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,6 +219,8 @@ export function useDecryptedMessage({
|
||||
!repairAttemptedByMessageId.current.has(message.id);
|
||||
|
||||
if (shouldAttemptPendingRepair) {
|
||||
beginEncryptionRecovery('pending-recipient');
|
||||
try {
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
@@ -270,9 +276,16 @@ export function useDecryptedMessage({
|
||||
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,
|
||||
@@ -284,6 +297,20 @@ export function useDecryptedMessage({
|
||||
|
||||
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);
|
||||
|
||||
@@ -301,6 +328,14 @@ export function useDecryptedMessage({
|
||||
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,
|
||||
@@ -317,13 +352,15 @@ export function useDecryptedMessage({
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
didDecrypt = true;
|
||||
return;
|
||||
}
|
||||
} 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);
|
||||
// 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
|
||||
@@ -335,6 +372,13 @@ export function useDecryptedMessage({
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* 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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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