Add pack icon system and E2EE removal plan

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

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

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

No files matched your search

File diff suppressed because it is too large. Load diff
+6 -4
View File
@@ -16,6 +16,7 @@ import Animated, {
} from "react-native-reanimated";
import { GradientBackground, GlassCard } from "../../src/components/ui";
import { colors, gradients, spacing, radius, typography } from "../../src/theme";
import { getPackIconName } from "../../src/lib/packIcons";
import { Question } from "../../src/types";
type TooltipType = 'initiator' | 'coupleType' | null;
@@ -259,7 +260,11 @@ export default function PackDetailsScreen() {
style={StyleSheet.absoluteFill}
/>
)}
<Text style={styles.emoji}>{pack?.icon || "📦"}</Text>
<Ionicons
name={getPackIconName(pack?.icon)}
size={32}
color={pack?.is_premium ? colors.premium.gold : colors.textSecondary}
/>
</View>
{/* Crown for premium */}
{pack?.is_premium && (
@@ -575,9 +580,6 @@ const styles = StyleSheet.create({
shadowRadius: 4,
elevation: 4,
},
emoji: {
fontSize: 36,
},
title: {
...typography.largeTitle,
color: colors.text,
+6 -4
View File
@@ -19,6 +19,7 @@ import Animated, {
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { supabase } from "../lib/supabase";
import { getPackIconName } from "../lib/packIcons";
import { colors, gradients, spacing, radius, typography } from "../theme";
interface PackTeaserProps {
@@ -159,7 +160,11 @@ export function PackTeaser({
colors={['rgba(212, 175, 55, 0.15)', 'rgba(184, 134, 11, 0.1)']}
style={StyleSheet.absoluteFill}
/>
<Text style={styles.packEmoji}>{packIcon || "📦"}</Text>
<Ionicons
name={getPackIconName(packIcon)}
size={36}
color={colors.premium.gold}
/>
</View>
{/* Crown badge */}
<View style={styles.crownBadge}>
@@ -370,9 +375,6 @@ const styles = StyleSheet.create({
shadowRadius: 4,
elevation: 4,
},
packEmoji: {
fontSize: 40,
},
title: {
...typography.title1,
color: colors.text,
+42 -1
View File
@@ -34,7 +34,7 @@ export const ChatScreen: React.FC = () => {
const matchId = id as string;
const router = useRouter();
const insets = useSafeAreaInsets();
const { user, partner } = useAuthStore();
const { user, partner, encryptionKeys } = useAuthStore();
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
const [match, setMatch] = useState<any>(null);
const [inputText, setInputText] = useState("");
@@ -77,6 +77,20 @@ export const ChatScreen: React.FC = () => {
// E2EE encryption hook for sending messages
const { encryptMessage, isE2EEAvailable, isAdminKeyReady, isEncrypting } = useEncryptedSend();
const secureReady = isE2EEAvailable && isAdminKeyReady;
const recoveryMessage = (() => {
if (!encryptionKeys.isRecovering) return null;
switch (encryptionKeys.recoveryReason) {
case 'pending-recipient':
return 'Finishing security setup for older messages...';
case 'keys-unavailable':
return 'Setting up secure keys...';
case 'stale-key':
case 'rotation':
return 'Updating security for this device...';
default:
return 'Updating security...';
}
})();
// Message actions hook for deletion and reporting
const { showDeleteOptions, deleteForSelf } = useMessageActions({
@@ -321,6 +335,14 @@ export const ChatScreen: React.FC = () => {
onBack={() => router.push('/(app)/matches')}
/>
{recoveryMessage && (
<View style={styles.securityBanner}>
<Ionicons name="shield-checkmark-outline" size={16} color={ACCENT} />
<Text style={styles.securityBannerText}>{recoveryMessage}</Text>
<ActivityIndicator size="small" color={ACCENT} />
</View>
)}
<ChatMessages
messages={messages}
userId={user?.id}
@@ -563,6 +585,25 @@ const styles = StyleSheet.create({
height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
securityBanner: {
marginHorizontal: spacing.md,
marginTop: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: 'rgba(212, 175, 55, 0.12)',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.25)',
},
securityBannerText: {
flex: 1,
color: colors.textSecondary,
fontSize: 13,
fontWeight: '600',
},
secureStatus: {
alignSelf: 'flex-start',
flexDirection: 'row',
+106 -76
View File
@@ -8,12 +8,19 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import { decryptMediaFile, cleanupDecryptedMedia, MESSAGE_VERSION_E2EE, repairStaleKey } from '../lib/encryption';
import {
decryptMediaFile,
cleanupDecryptedMedia,
MESSAGE_VERSION_E2EE,
repairStaleKey,
triggerAutoKeyRotation,
} from '../lib/encryption';
import type { KeysMetadata, RSAPrivateKeyJWK } from '../lib/encryption';
import { useEncryptionKeys } from './useEncryptionKeys';
import { getCachedSignedUrl, getStoragePath } from '../lib/imageCache';
import { supabase } from '../lib/supabase';
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
import { useAuthStore } from '../store/authStore';
// Import shared cache from mediaCache to avoid circular dependency
import { decryptedMediaCache, clearDecryptedMediaCache } from '../lib/mediaCache';
@@ -86,6 +93,8 @@ export function useDecryptedMedia({
});
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
const beginEncryptionRecovery = useAuthStore((s) => s.beginEncryptionRecovery);
const endEncryptionRecovery = useAuthStore((s) => s.endEncryptionRecovery);
const isMounted = useRef(true);
const repairAttemptedByMessageId = useRef(new Set<string>());
@@ -216,26 +225,31 @@ export function useDecryptedMedia({
);
if (!isMe) {
setState({
uri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
beginEncryptionRecovery('pending-recipient');
try {
setState({
uri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
if (!repairAttemptedByMessageId.current.has(messageId)) {
repairAttemptedByMessageId.current.add(messageId);
await reencryptPendingMessages();
if (!repairAttemptedByMessageId.current.has(messageId)) {
repairAttemptedByMessageId.current.add(messageId);
await reencryptPendingMessages();
}
const { data: refreshed } = await supabase
.from('messages')
.select('keys_metadata')
.eq('id', messageId)
.maybeSingle();
effectiveKeysMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
wrappedKey = getWrappedKey(effectiveKeysMetadata);
} finally {
endEncryptionRecovery();
}
const { data: refreshed } = await supabase
.from('messages')
.select('keys_metadata')
.eq('id', messageId)
.maybeSingle();
effectiveKeysMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
wrappedKey = getWrappedKey(effectiveKeysMetadata);
}
if (!wrappedKey) {
@@ -298,67 +312,80 @@ export function useDecryptedMedia({
// Attempt stale key repair if not already attempted
if (isStaleKeyFailure && !repairAttemptedByMessageId.current.has(messageId)) {
console.log(`[E2EE Media] Message ${messageId}: Attempting stale key repair...`);
if (isMounted.current) {
setState({
uri: null,
isDecrypting: true,
error: null,
errorCode: undefined,
});
}
repairAttemptedByMessageId.current.add(messageId);
beginEncryptionRecovery('stale-key');
const autoRotationPromise = triggerAutoKeyRotation();
let didDecrypt = false;
try {
const repairResult = await repairStaleKey(messageId);
console.log(`[E2EE Media] Message ${messageId}: Attempting stale key repair...`);
if (repairResult.success) {
console.log(`[E2EE Media] Message ${messageId}: Stale key repaired, retrying decryption...`);
// Fetch updated message and retry
const { data: refreshed } = await supabase
.from('messages')
.select('keys_metadata')
.eq('id', messageId)
.maybeSingle();
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
if (refreshedMetadata && encryptionIv && mediaPath) {
const storagePath = getStoragePath(mediaPath);
const encryptedUrl = await getCachedSignedUrl(storagePath);
if (encryptedUrl) {
const decryptedUri = await decryptMediaFile(
encryptedUrl,
encryptionIv,
refreshedMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
!isMe,
mediaType,
);
const cacheKey = `${messageId}-${mediaPath}`;
decryptedMediaCache.set(cacheKey, decryptedUri);
if (isMounted.current) {
setState({
uri: decryptedUri,
isDecrypting: false,
error: null,
errorCode: undefined,
});
}
return;
}
}
} else {
console.error(`[E2EE Media] Message ${messageId}: Stale key repair failed:`, repairResult.error);
if (isMounted.current) {
setState({
uri: null,
isDecrypting: true,
error: null,
errorCode: undefined,
});
}
} catch (repairErr) {
console.error(`[E2EE Media] Message ${messageId}: Stale key repair threw:`, repairErr);
repairAttemptedByMessageId.current.add(messageId);
try {
const repairResult = await repairStaleKey(messageId);
if (repairResult.success) {
console.log(`[E2EE Media] Message ${messageId}: Stale key repaired, retrying decryption...`);
// Fetch updated message and retry
const { data: refreshed } = await supabase
.from('messages')
.select('keys_metadata')
.eq('id', messageId)
.maybeSingle();
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
if (refreshedMetadata && encryptionIv && mediaPath) {
const storagePath = getStoragePath(mediaPath);
const encryptedUrl = await getCachedSignedUrl(storagePath);
if (encryptedUrl) {
const decryptedUri = await decryptMediaFile(
encryptedUrl,
encryptionIv,
refreshedMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
!isMe,
mediaType,
);
const cacheKey = `${messageId}-${mediaPath}`;
decryptedMediaCache.set(cacheKey, decryptedUri);
if (isMounted.current) {
setState({
uri: decryptedUri,
isDecrypting: false,
error: null,
errorCode: undefined,
});
}
didDecrypt = true;
return;
}
}
} else {
console.error(`[E2EE Media] Message ${messageId}: Stale key repair failed:`, repairResult.error);
}
} catch (repairErr) {
console.error(`[E2EE Media] Message ${messageId}: Stale key repair threw:`, repairErr);
}
} finally {
await autoRotationPromise;
if (autoRotationPromise && !didDecrypt) {
retry();
}
endEncryptionRecovery();
}
}
@@ -385,6 +412,9 @@ export function useDecryptedMedia({
hasKeys,
keysLoading,
retryNonce,
beginEncryptionRecovery,
endEncryptionRecovery,
retry,
]);
useEffect(() => {
+146 -98
View File
@@ -12,6 +12,7 @@ import {
MESSAGE_VERSION_PLAINTEXT,
MESSAGE_VERSION_E2EE,
repairStaleKey,
triggerAutoKeyRotation,
} from '../lib/encryption';
import type {
DecryptedMessageState,
@@ -21,6 +22,7 @@ import type {
import { supabase } from '../lib/supabase';
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
import { useEncryptionKeys } from './useEncryptionKeys';
import { useAuthStore } from '../store/authStore';
const AUTO_RETRY_BASE_DELAY_MS = 3000;
const MAX_AUTO_RETRIES = 2;
@@ -67,7 +69,9 @@ export function useDecryptedMessage({
error: null,
});
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
const { privateKeyJwk, hasKeys, isLoading: keysLoading, ensureKeysUploaded } = useEncryptionKeys();
const beginEncryptionRecovery = useAuthStore((s) => s.beginEncryptionRecovery);
const endEncryptionRecovery = useAuthStore((s) => s.endEncryptionRecovery);
// Track per-message repair attempts to avoid tight retry loops.
const repairAttemptedByMessageId = useRef(new Set<string>());
@@ -215,99 +219,40 @@ export function useDecryptedMessage({
!repairAttemptedByMessageId.current.has(message.id);
if (shouldAttemptPendingRepair) {
setState({
content: null,
mediaUri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
if (!repairAttemptedByMessageId.current.has(message.id)) {
repairAttemptedByMessageId.current.add(message.id);
await reencryptPendingMessages();
}
// Fetch the latest keys_metadata and retry once locally.
const { data: refreshed } = await supabase
.from('messages')
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
.eq('id', message.id)
.maybeSingle();
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
const refreshedIv = refreshed?.encryption_iv ?? null;
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
try {
const decryptedContent = await decryptTextMessage(
refreshedEncryptedContent,
refreshedIv,
refreshedMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
true, // isRecipient
);
setState({
content: decryptedContent,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: undefined,
});
return;
} catch {
// Still pending (or repair failed); show the placeholder instead of a hard error.
}
}
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
return;
}
if (shouldAttemptStaleKeyRepair) {
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting stale key repair...`);
setState({
content: null,
mediaUri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_REPAIRING_STALE_KEY',
});
repairAttemptedByMessageId.current.add(message.id);
beginEncryptionRecovery('pending-recipient');
try {
const repairResult = await repairStaleKey(message.id);
setState({
content: null,
mediaUri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
if (repairResult.success) {
console.log(`[E2EE Decrypt] Message ${message.id}: Stale key repaired, retrying decryption...`);
if (!repairAttemptedByMessageId.current.has(message.id)) {
repairAttemptedByMessageId.current.add(message.id);
await reencryptPendingMessages();
}
// Fetch updated message and retry
const { data: refreshed } = await supabase
.from('messages')
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
.eq('id', message.id)
.maybeSingle();
// Fetch the latest keys_metadata and retry once locally.
const { data: refreshed } = await supabase
.from('messages')
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
.eq('id', message.id)
.maybeSingle();
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
const refreshedIv = refreshed?.encryption_iv ?? null;
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
const refreshedIv = refreshed?.encryption_iv ?? null;
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
try {
const decryptedContent = await decryptTextMessage(
refreshedEncryptedContent,
refreshedIv,
refreshedMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
!isMe, // isRecipient
true, // isRecipient
);
setState({
@@ -318,23 +263,122 @@ export function useDecryptedMessage({
errorCode: undefined,
});
return;
} catch {
// Still pending (or repair failed); show the placeholder instead of a hard error.
}
} else {
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair failed:`, repairResult.error);
}
} catch (repairErr) {
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair threw:`, repairErr);
}
// Repair failed or retry failed
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Waiting for message...'),
errorCode: 'E2EE_DECRYPT_FAILED',
});
return;
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
return;
} finally {
endEncryptionRecovery();
}
}
if (shouldAttemptStaleKeyRepair) {
beginEncryptionRecovery('stale-key');
let didDecrypt = false;
try {
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting stale key repair...`);
setState({
content: null,
mediaUri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_REPAIRING_STALE_KEY',
});
repairAttemptedByMessageId.current.add(message.id);
// CRITICAL: Ensure local keys are uploaded to database BEFORE repair.
// The repair edge function uses the database public key to re-wrap.
// If the database has a stale key, repair will "succeed" but decryption will fail.
console.log(`[E2EE Decrypt] Message ${message.id}: Ensuring keys are synced before repair...`);
const keysUploaded = await ensureKeysUploaded();
console.log(`[E2EE Decrypt] Message ${message.id}: Keys sync result: ${keysUploaded}`);
// Run auto key rotation to re-wrap recent messages BEFORE individual repair.
// This ensures handle-key-rotation uses the correct database key.
const autoRotationResult = await triggerAutoKeyRotation();
if (autoRotationResult) {
console.log(`[E2EE Decrypt] Message ${message.id}: Auto rotation completed: ${autoRotationResult.updated} messages updated`);
}
try {
const repairResult = await repairStaleKey(message.id);
if (repairResult.success) {
console.log(`[E2EE Decrypt] Message ${message.id}: Stale key repaired, retrying decryption...`);
// Fetch updated message and retry
const { data: refreshed } = await supabase
.from('messages')
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
.eq('id', message.id)
.maybeSingle();
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
const refreshedIv = refreshed?.encryption_iv ?? null;
// Log key metadata for debugging
console.log(`[E2EE Decrypt] Message ${message.id}: Refreshed metadata:`, {
hasSenderKey: !!refreshedMetadata?.sender_wrapped_key,
hasRecipientKey: !!refreshedMetadata?.recipient_wrapped_key,
adminKeyId: refreshedMetadata?.admin_key_id,
isRecipient: !isMe,
});
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
const decryptedContent = await decryptTextMessage(
refreshedEncryptedContent,
refreshedIv,
refreshedMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
!isMe, // isRecipient
);
setState({
content: decryptedContent,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: undefined,
});
didDecrypt = true;
return;
}
} else {
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair failed:`, repairResult.error);
}
} catch (repairErr) {
// Note: This catch includes decryption errors after successful repair
console.error(`[E2EE Decrypt] Message ${message.id}: Decryption retry failed after repair:`, repairErr);
}
// Repair failed or retry failed
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Waiting for message...'),
errorCode: 'E2EE_DECRYPT_FAILED',
});
return;
} finally {
if (!didDecrypt) {
// Schedule another retry after a delay - keys may sync in the background
retry();
}
endEncryptionRecovery();
}
}
setState({
@@ -359,6 +403,10 @@ export function useDecryptedMessage({
hasKeys,
keysLoading,
retryNonce,
beginEncryptionRecovery,
endEncryptionRecovery,
retry,
ensureKeysUploaded,
]);
useEffect(() => {
@@ -40,6 +40,8 @@ interface UseEncryptionKeysReturn {
initializeAndUploadKeys: () => Promise<{ wasRegenerated: boolean }>;
/** Refresh the key state from storage */
refresh: () => Promise<void>;
/** Ensure local keys are uploaded to database. Blocks until complete. */
ensureKeysUploaded: () => Promise<boolean>;
}
// Global promise to track in-flight key loading across all hook instances
@@ -274,6 +276,11 @@ export function useEncryptionKeys(): UseEncryptionKeysReturn {
// Upload if remote key is missing/invalid or doesn't match local.
// If we just regenerated the key, we MUST upload it regardless
if (publicKey && localValid && !sameKey) {
// Treat a mismatch as regeneration so we trigger key rotation later.
wasRegenerated = true;
}
if ((publicKey && localValid && !sameKey) || (publicKey && wasRegenerated)) {
console.log('[E2EE Global] Uploading public key to profile...');
@@ -323,6 +330,75 @@ export function useEncryptionKeys(): UseEncryptionKeysReturn {
await loadKeys();
}, [loadKeys]);
/**
* Ensure local keys are uploaded to database.
* Blocks until upload is complete or fails.
* This is critical for E2EE repair flows where the edge function needs
* the current public key to re-wrap message AES keys.
*/
const ensureKeysUploaded = useCallback(async (): Promise<boolean> => {
if (Platform.OS === 'web') {
console.log('[E2EE] ensureKeysUploaded: Web platform, skipping');
return false;
}
if (!user?.id) {
console.log('[E2EE] ensureKeysUploaded: No user ID, skipping');
return false;
}
try {
// Check if local key matches database key
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('public_key_jwk')
.eq('id', user.id)
.maybeSingle();
if (profileError) {
console.error('[E2EE] ensureKeysUploaded: Failed to fetch profile:', profileError);
// Fall through to initializeAndUploadKeys which will handle this
}
const remoteKey = (profile?.public_key_jwk ?? null) as RSAPublicKeyJWK | null;
const localKey = encryptionKeys.publicKeyJwk;
if (!localKey) {
// No local key, need to initialize
console.log('[E2EE] ensureKeysUploaded: No local key, initializing...');
await initializeAndUploadKeys();
return true;
}
// Compare keys (sanitize both for accurate comparison)
const sanitizedLocal = sanitizePublicKeyJwk(localKey);
const sanitizedRemote = remoteKey ? sanitizePublicKeyJwk(remoteKey) : null;
const keysMatch = sanitizedRemote &&
sanitizedLocal.n === sanitizedRemote.n &&
sanitizedLocal.e === sanitizedRemote.e;
if (!keysMatch) {
// Mismatch - upload local key
console.log('[E2EE] ensureKeysUploaded: Key mismatch detected, uploading local key...');
await initializeAndUploadKeys();
return true;
}
console.log('[E2EE] ensureKeysUploaded: Keys already synced');
return true;
} catch (error) {
console.error('[E2EE] ensureKeysUploaded: Error:', error);
// Try to initialize anyway as a fallback
try {
await initializeAndUploadKeys();
return true;
} catch {
return false;
}
}
}, [user?.id, encryptionKeys.publicKeyJwk, initializeAndUploadKeys]);
// Load keys on mount if not already loaded
useEffect(() => {
if (!hasLoadedRef.current && !isLoadingRef.current && !encryptionKeys.hasKeys) {
@@ -345,5 +421,6 @@ export function useEncryptionKeys(): UseEncryptionKeysReturn {
error: encryptionKeys.keysError,
initializeAndUploadKeys,
refresh,
ensureKeysUploaded,
};
}
+6
View File
@@ -8,6 +8,12 @@ export async function initAnalytics() {
return;
}
// Skip initialization in non-RN environments (e.g., during tsc/Node.js)
// AsyncStorage requires window to be defined
if (typeof window === "undefined" && typeof global !== "undefined" && !("__BUNDLE_START_TIME__" in global)) {
return;
}
const apiKey = process.env.EXPO_PUBLIC_POSTHOG_API_KEY;
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
+1 -1
View File
@@ -61,7 +61,7 @@ export {
} from './utils';
// Key Rotation
export { triggerKeyRotation } from './triggerKeyRotation';
export { triggerKeyRotation, triggerAutoKeyRotation } from './triggerKeyRotation';
// Stale Key Repair
export { repairStaleKey } from './repairStaleKey';
@@ -9,7 +9,9 @@
* the user's new public key.
*/
import { supabase } from '../supabase';
import { supabase } from '../supabase';
import { reencryptPendingMessages } from './reencryptPendingMessages';
export interface KeyRotationResult {
success: boolean;
@@ -18,55 +20,83 @@ export interface KeyRotationResult {
error?: string;
}
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> {
// Prevent concurrent calls
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
return null;
}
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
if (!baseUrl) {
return { success: false, updated: 0, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
}
console.log('[E2EE] Triggering key rotation for regenerated keys...');
const response = await fetch(`${baseUrl}/functions/v1/handle-key-rotation`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
const text = await response.text();
console.error('[E2EE] Key rotation failed:', text);
return { success: false, updated: 0, error: text || 'Failed to rotate keys' };
}
const result = (await response.json()) as KeyRotationResult;
console.log(`[E2EE] Key rotation complete: ${result.updated} messages updated`);
return result;
} catch (err) {
console.error('[E2EE] Key rotation error:', err);
return {
success: false,
updated: 0,
error: err instanceof Error ? err.message : 'Unknown error',
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
export async function triggerKeyRotation(): Promise<KeyRotationResult | null> {
// Prevent concurrent calls
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
return null;
}
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
if (!baseUrl) {
return { success: false, updated: 0, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
}
console.log('[E2EE] Triggering key rotation for regenerated keys...');
const response = await fetch(`${baseUrl}/functions/v1/handle-key-rotation`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
const text = await response.text();
console.error('[E2EE] Key rotation failed:', text);
return { success: false, updated: 0, error: text || 'Failed to rotate keys' };
}
const result = (await response.json()) as KeyRotationResult;
console.log(`[E2EE] Key rotation complete: ${result.updated} messages updated`);
return result;
} catch (err) {
console.error('[E2EE] Key rotation error:', err);
return {
success: false,
updated: 0,
error: err instanceof Error ? err.message : 'Unknown error',
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
export function triggerAutoKeyRotation(): Promise<KeyRotationResult | null> | null {
const now = Date.now();
if (autoRotationInFlight) return autoRotationInFlight;
if (now - lastAutoRotationAt < AUTO_ROTATION_MIN_INTERVAL_MS) {
return null;
}
lastAutoRotationAt = now;
autoRotationInFlight = (async () => {
console.log('[E2EE] Auto key rotation triggered by decryption failure.');
const result = await triggerKeyRotation();
await reencryptPendingMessages({ force: true });
return result;
})().finally(() => {
autoRotationInFlight = null;
});
return autoRotationInFlight;
}
+61
View File
@@ -0,0 +1,61 @@
import type { Ionicons } from "@expo/vector-icons";
type IoniconsName = keyof typeof Ionicons.glyphMap;
/**
* Maps emoji icons to Ionicons for a more professional, consistent appearance.
* This eliminates the "AI slop" feel of heavy emoji usage while maintaining
* visual differentiation between packs.
*/
export const PACK_ICON_MAP: Record<string, IoniconsName> = {
// Career & Professional
"💼": "briefcase-outline",
// Relationships & Connection
"🔗": "heart-half-outline",
"❤️": "heart-outline",
"💫": "sparkles-outline",
// Secrets & Mystery
"🤫": "eye-off-outline",
"🗝️": "key-outline",
// Romance & Dates
"🌹": "flower-outline",
"🍷": "wine-outline",
"✨": "sparkles-outline",
// Adventure & Travel
"✈️": "airplane-outline",
"🚗": "car-outline",
"🏡": "home-outline",
// Playful & Games
"🎭": "color-wand-outline",
"🎲": "dice-outline",
"🎁": "gift-outline",
// Intimacy & Physical
"🔄": "sync-outline",
"😈": "flash-outline",
"🔥": "flame-outline",
// Goals & Planning
"🎯": "flag-outline",
"☀️": "sunny-outline",
// Default fallbacks
"📦": "cube-outline",
"📁": "folder-outline",
};
export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline";
/**
* Converts an emoji icon string to an Ionicons name.
* Falls back to DEFAULT_PACK_ICON if no mapping exists.
*/
export function getPackIconName(emoji: string | null | undefined): IoniconsName {
if (!emoji) return DEFAULT_PACK_ICON;
return PACK_ICON_MAP[emoji] || DEFAULT_PACK_ICON;
}
+88 -40
View File
@@ -10,15 +10,23 @@ 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.
*/
interface EncryptionKeyState {
privateKeyJwk: RSAPrivateKeyJWK | null;
publicKeyJwk: RSAPublicKeyJWK | null;
hasKeys: boolean;
isLoadingKeys: boolean;
keysError: Error | null;
/** Incremented when keys change - consumers can use this to trigger re-renders */
keysVersion: number;
}
type EncryptionRecoveryReason = 'stale-key' | 'pending-recipient' | 'rotation' | 'keys-unavailable';
interface EncryptionKeyState {
privateKeyJwk: RSAPrivateKeyJWK | null;
publicKeyJwk: RSAPublicKeyJWK | null;
hasKeys: boolean;
isLoadingKeys: boolean;
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;
@@ -37,9 +45,12 @@ interface AuthState {
signOut: () => Promise<void>;
setUser: (user: Profile | null) => void;
// Encryption key actions
setEncryptionKeys: (keys: Partial<EncryptionKeyState>) => void;
clearEncryptionKeys: () => void;
// 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
@@ -51,14 +62,19 @@ const getOtherStores = () => {
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore };
};
const initialEncryptionKeyState: EncryptionKeyState = {
privateKeyJwk: null,
publicKeyJwk: null,
hasKeys: false,
isLoadingKeys: true,
keysError: null,
keysVersion: 0,
};
const initialEncryptionKeyState: EncryptionKeyState = {
privateKeyJwk: null,
publicKeyJwk: null,
hasKeys: false,
isLoadingKeys: true,
keysError: null,
keysVersion: 0,
isRecovering: false,
recoveryReason: null,
recoveryCount: 0,
recoveryUpdatedAt: null,
};
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
@@ -235,23 +251,55 @@ export const useAuthStore = create<AuthState>((set, get) => ({
* Update encryption key state. This is the ONLY way to update keys
* to ensure all consumers see the same state.
*/
setEncryptionKeys: (keys) => {
set((state) => ({
encryptionKeys: {
...state.encryptionKeys,
...keys,
// Increment version when keys actually change
keysVersion: (keys.publicKeyJwk !== undefined || keys.privateKeyJwk !== undefined)
? state.encryptionKeys.keysVersion + 1
: state.encryptionKeys.keysVersion,
},
}));
},
/**
* Clear encryption keys (called on sign out or when leaving couple)
*/
clearEncryptionKeys: () => {
set({ encryptionKeys: initialEncryptionKeyState });
},
}));
setEncryptionKeys: (keys) => {
set((state) => ({
encryptionKeys: {
...state.encryptionKeys,
...keys,
// Increment version when keys actually change
keysVersion: (keys.publicKeyJwk !== undefined || keys.privateKeyJwk !== undefined)
? state.encryptionKeys.keysVersion + 1
: state.encryptionKeys.keysVersion,
},
}));
},
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)
*/
clearEncryptionKeys: () => {
set({ encryptionKeys: initialEncryptionKeyState });
},
}));