(
- { name: '', description: '', icon: 'π' },
+ { name: '', description: '', icon: 'bookmark-outline' },
(category) => ({
name: category.name,
description: category.description || '',
- icon: category.icon || 'π',
+ icon: category.icon || 'bookmark-outline',
})
);
@@ -118,7 +118,7 @@ export function CategoriesPage() {
form.openCreateWith({
name: idea.name,
description: idea.description || '',
- icon: idea.icon || 'π',
+ icon: idea.icon || 'bookmark-outline',
});
};
@@ -261,9 +261,9 @@ export function CategoriesPage() {
- form.setField('icon', emoji)}
+ onChange={(iconName) => form.setField('icon', iconName)}
/>
@@ -363,7 +363,9 @@ export function CategoriesPage() {
- {category.icon || 'π'}
+
+
+
- {pack.icon || 'π'}
+
+
+
{pack.is_premium && (
diff --git a/apps/mobile/app.json b/apps/mobile/app.json
index 1c14d1a..51a128b 100644
--- a/apps/mobile/app.json
+++ b/apps/mobile/app.json
@@ -23,8 +23,8 @@
"remote-notification"
],
"NSFaceIDUsageDescription": "Sauci uses Face ID to unlock the app and keep your conversations private.",
- "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.",
+ "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.",
"NSPhotoLibraryAddUsageDescription": "Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.",
"NSMicrophoneUsageDescription": "Sauci uses your microphone to record audio when you capture videos to share privately with your partner."
},
diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx
index 07937a0..b6cc745 100755
--- a/apps/mobile/app/(app)/_layout.tsx
+++ b/apps/mobile/app/(app)/_layout.tsx
@@ -5,8 +5,6 @@ import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import { useEffect, useState, useCallback, useRef } from "react";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore } from "../../src/store";
-import { useDecryptedMessage } from "../../src/hooks";
-import type { KeysMetadata } from "../../src/lib/encryption";
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
import { supabase } from "../../src/lib/supabase";
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
@@ -20,10 +18,8 @@ type Message = Database["public"]["Tables"]["messages"]["Row"];
function MessageToastPreview({
message,
- currentUserId,
}: {
message: Message;
- currentUserId: string;
}) {
if (message.media_path) {
const fallback = message.media_type === 'video' ? 'Sent a video' : 'Sent an image';
@@ -34,41 +30,9 @@ function MessageToastPreview({
);
}
- const version = message.version ?? 1;
- const isV2 = version === 2;
-
- const { content, isDecrypting, error } = useDecryptedMessage({
- message: {
- id: message.id,
- content: message.content,
- version,
- encrypted_content: message.encrypted_content,
- encryption_iv: message.encryption_iv,
- keys_metadata: message.keys_metadata as unknown as KeysMetadata | null,
- user_id: message.user_id,
- },
- currentUserId,
- });
-
- if (isDecrypting) {
- return (
-
- Waiting for message...
-
- );
- }
-
- if (error) {
- return (
-
- Encrypted message
-
- );
- }
-
return (
- {content ?? (!isV2 ? message.content : null) ?? 'New message'}
+ {message.content ?? 'New message'}
);
}
@@ -886,7 +850,7 @@ export default function AppLayout() {
New message
-
+
diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
index 2ad7fc2..5dfc605 100755
--- a/apps/mobile/app/_layout.tsx
+++ b/apps/mobile/app/_layout.tsx
@@ -48,7 +48,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import { useAuthStore } from "../src/store";
import { supabase } from "../src/lib/supabase";
-import { EncryptionKeyInitializer } from "../src/components/EncryptionKeyInitializer";
const queryClient = new QueryClient();
@@ -213,7 +212,6 @@ export default function RootLayout() {
-
{
{
{
{
{
expect(onSend).toHaveBeenCalledTimes(1);
});
- it('disables send when secure messaging is not ready', () => {
- const onSend = jest.fn();
-
- const { getByTestId } = render(
-
- );
-
- fireEvent.press(getByTestId('chat-input-send'));
- expect(onSend).not.toHaveBeenCalled();
- });
-
it('calls media actions from menu buttons', () => {
const onPickMedia = jest.fn();
const onTakePhoto = jest.fn();
@@ -105,7 +81,6 @@ describe('InputBar', () => {
{
- let store: Record = {};
- return {
- setItemAsync: jest.fn(async (key, value) => {
- store[key] = value;
- }),
- getItemAsync: jest.fn(async (key) => {
- return store[key] || null;
- }),
- deleteItemAsync: jest.fn(async (key) => {
- delete store[key];
- }),
- _clear: () => {
- store = {};
- },
- };
-});
-
-jest.mock('react-native', () => ({
- Platform: {
- OS: 'ios', // Mock as native platform to allow key generation
- },
-}));
-
-// Mock react-native-quick-crypto's polyfill behavior if needed,
-// but Node.js 20+ has global.crypto.subtle.
-// If tests fail due to missing crypto, we will need to polyfill it.
-if (!global.crypto) {
- // @ts-ignore
- global.crypto = require('crypto').webcrypto;
-}
-
-describe('E2EE Encryption Library', () => {
- // 1. Low-Level Crypto Primitives
- describe('Crypto Primitives (AES-GCM)', () => {
- it('should generate a valid AES-256 key', async () => {
- const key = await generateAESKey();
- expect(key.algorithm.name).toBe('AES-GCM');
- // @ts-ignore - length is present in Web Crypto keys
- expect(key.algorithm.length).toBe(256);
- expect(key.extractable).toBe(true);
- });
-
- it('should generate a valid 12-byte IV', () => {
- const iv = generateIV();
- expect(iv.length).toBe(12);
- expect(iv).toBeInstanceOf(Uint8Array);
- });
-
- it('should correctly round-trip AES encryption/decryption', async () => {
- const plaintext = 'Hello, Secret World!';
- const key = await generateAESKey();
- const iv = generateIV();
-
- const ciphertext = await encryptAES(plaintext, key, iv);
- const decryptedBuffer = await decryptAES(ciphertext, key, iv);
- const decryptedText = new TextDecoder().decode(decryptedBuffer);
-
- expect(decryptedText).toBe(plaintext);
- });
-
- it('should export and import AES keys correctly', async () => {
- const originalKey = await generateAESKey();
- const rawKey = await exportAESKey(originalKey);
-
- expect(rawKey.byteLength).toBe(32); // 256 bits = 32 bytes
-
- const importedKey = await importAESKey(rawKey);
-
- expect(importedKey.algorithm.name).toBe('AES-GCM');
- // @ts-ignore
- expect(importedKey.algorithm.length).toBe(256);
- });
- });
-
- // 2. RSA Key Wrapping
- describe('RSA Key Wrapping', () => {
- let aliceKeyPair: CryptoKeyPair;
- let alicePublicKeyJwk: RSAPublicKeyJWK;
- let alicePrivateKeyJwk: RSAPrivateKeyJWK;
-
- beforeAll(async () => {
- // Generate a real keypair for testing using Web Crypto directly
- // simulating what generateAndStoreKeyPair does internally
- aliceKeyPair = await crypto.subtle.generateKey(
- {
- name: 'RSA-OAEP',
- modulusLength: 2048,
- publicExponent: new Uint8Array([1, 0, 1]),
- hash: 'SHA-256',
- },
- true,
- ['encrypt', 'decrypt']
- );
-
- alicePublicKeyJwk = (await crypto.subtle.exportKey('jwk', aliceKeyPair.publicKey)) as RSAPublicKeyJWK;
- alicePrivateKeyJwk = (await crypto.subtle.exportKey('jwk', aliceKeyPair.privateKey)) as RSAPrivateKeyJWK;
- });
-
- it('should wrap and unwrap an AES key using RSA keys', async () => {
- const aesKey = await generateAESKey();
- const rawAesKey = await exportAESKey(aesKey);
-
- // Wrap with Public Key
- const wrappedKeyBase64 = await wrapAESKey(rawAesKey, alicePublicKeyJwk);
- expect(typeof wrappedKeyBase64).toBe('string');
-
- // Unwrap with Private Key
- const unwrappedRawKey = await unwrapAESKey(wrappedKeyBase64, alicePrivateKeyJwk);
-
- // Compare bytes
- const originalBytes = new Uint8Array(rawAesKey);
- const unwrappedBytes = new Uint8Array(unwrappedRawKey);
- expect(unwrappedBytes).toEqual(originalBytes);
- });
-
- it('should verify matching key pairs', async () => {
- const isMatch = await verifyKeyPair(alicePublicKeyJwk, alicePrivateKeyJwk);
- expect(isMatch).toBe(true);
- });
-
- it('should reject mismatched key pairs', async () => {
- // Suppress expected console error
- const originalError = console.error;
- console.error = jest.fn();
-
- try {
- // Generate a second, different keypair
- const bobKeyPair = await crypto.subtle.generateKey(
- {
- name: 'RSA-OAEP',
- modulusLength: 2048,
- publicExponent: new Uint8Array([1, 0, 1]),
- hash: 'SHA-256',
- },
- true,
- ['encrypt', 'decrypt']
- );
- const bobPrivateKeyJwk = (await crypto.subtle.exportKey('jwk', bobKeyPair.privateKey)) as RSAPrivateKeyJWK;
-
- // Check mismatch (Alice's Public + Bob's Private)
- const isMatch = await verifyKeyPair(alicePublicKeyJwk, bobPrivateKeyJwk);
- expect(isMatch).toBe(false);
- } finally {
- // Restore console error
- console.error = originalError;
- }
- });
- });
-
- // 3. Key Manager (with mocked SecureStore)
- describe('Key Manager', () => {
- beforeEach(() => {
- // Clear mocked store
- require('expo-secure-store')._clear();
- });
-
- it('should generate and store keys correctly', async () => {
- const publicKey = await generateAndStoreKeyPair();
-
- expect(publicKey.kty).toBe('RSA');
- expect(publicKey.alg).toBe('RSA-OAEP-256');
-
- // Check SecureStore interaction
- const storedPrivateKey = await getPrivateKey();
- expect(storedPrivateKey).toBeDefined();
- expect(storedPrivateKey?.d).toBeDefined(); // 'd' is the private exponent
-
- // Verify they match
- const isMatch = await verifyKeyPair(publicKey, storedPrivateKey!);
- expect(isMatch).toBe(true);
- });
-
- it('should clear keys', async () => {
- await generateAndStoreKeyPair();
- await clearKeys();
-
- const storedPrivateKey = await getPrivateKey();
- expect(storedPrivateKey).toBeNull();
- });
-
- it('should validate and sanitize public keys', () => {
- // Valid key
- const validKey: RSAPublicKeyJWK = {
- kty: 'RSA',
- n: 'some_base64_string',
- e: 'AQAB',
- alg: 'RSA-OAEP-256'
- };
- expect(isValidPublicKeyJwk(validKey)).toBe(true);
-
- // Invalid key (missing fields)
- expect(isValidPublicKeyJwk({} as any)).toBe(false);
-
- // Key with trailing dots (sanitization test)
- const dirtyKey: RSAPublicKeyJWK = {
- ...validKey,
- n: 'some_base64_string...',
- e: 'AQAB.'
- };
-
- const cleanKey = sanitizePublicKeyJwk(dirtyKey);
- expect(cleanKey.n).toBe('some_base64_string');
- expect(cleanKey.e).toBe('AQAB');
- expect(isValidPublicKeyJwk(dirtyKey)).toBe(true); // Should pass because it sanitizes internally
- });
- });
-
- // 4. High-Level Message Flow (Alice -> Bob)
- describe('Full Message Flow (Alice -> Bob)', () => {
- let aliceKeys: { pub: RSAPublicKeyJWK, priv: RSAPrivateKeyJWK };
- let bobKeys: { pub: RSAPublicKeyJWK, priv: RSAPrivateKeyJWK };
- let adminKeys: { pub: RSAPublicKeyJWK, priv: RSAPrivateKeyJWK };
-
- beforeAll(async () => {
- const genKey = async () => {
- const kp = await crypto.subtle.generateKey(
- {
- name: 'RSA-OAEP',
- modulusLength: 2048,
- publicExponent: new Uint8Array([1, 0, 1]),
- hash: 'SHA-256',
- },
- true,
- ['encrypt', 'decrypt']
- );
- return {
- pub: (await crypto.subtle.exportKey('jwk', kp.publicKey)) as RSAPublicKeyJWK,
- priv: (await crypto.subtle.exportKey('jwk', kp.privateKey)) as RSAPrivateKeyJWK,
- };
- };
-
- aliceKeys = await genKey();
- bobKeys = await genKey();
- adminKeys = await genKey();
- });
-
- it('should encrypt and decrypt a message successfully', async () => {
- const plaintext = "Meet me at the secret base.";
- const adminKeyId = "admin-key-uuid";
-
- // 1. Alice Encrypts
- const encryptedPayload = await encryptTextMessage(
- plaintext,
- aliceKeys.pub,
- bobKeys.pub,
- adminKeys.pub,
- adminKeyId
- );
-
- expect(encryptedPayload.version).toBe(2);
- expect(encryptedPayload.keys_metadata.algorithm).toBe('AES-256-GCM');
- expect(encryptedPayload.keys_metadata.sender_wrapped_key).toBeDefined();
- expect(encryptedPayload.keys_metadata.recipient_wrapped_key).toBeDefined();
- expect(encryptedPayload.keys_metadata.admin_wrapped_key).toBeDefined();
-
- // 2. Bob Decrypts (as Recipient)
- const decryptedByBob = await decryptTextMessage(
- encryptedPayload.encrypted_content,
- encryptedPayload.encryption_iv,
- encryptedPayload.keys_metadata,
- bobKeys.priv,
- true // isRecipient
- );
-
- expect(decryptedByBob).toBe(plaintext);
-
- // 3. Alice Decrypts (as Sender) - e.g. for history
- const decryptedByAlice = await decryptTextMessage(
- encryptedPayload.encrypted_content,
- encryptedPayload.encryption_iv,
- encryptedPayload.keys_metadata,
- aliceKeys.priv,
- false // isRecipient = false
- );
-
- expect(decryptedByAlice).toBe(plaintext);
- });
-
- it('should handle pending recipient (no recipient key)', async () => {
- const plaintext = "Waiting for you to join...";
- const adminKeyId = "admin-key-uuid";
-
- // Encrypt with NO recipient key (null)
- const encryptedPayload = await encryptTextMessage(
- plaintext,
- aliceKeys.pub,
- null, // No recipient key
- adminKeys.pub,
- adminKeyId
- );
-
- expect(encryptedPayload.keys_metadata.recipient_wrapped_key).toBeUndefined();
- expect(encryptedPayload.keys_metadata.pending_recipient).toBe(true);
-
- // Sender (Alice) can still decrypt
- const decryptedByAlice = await decryptTextMessage(
- encryptedPayload.encrypted_content,
- encryptedPayload.encryption_iv,
- encryptedPayload.keys_metadata,
- aliceKeys.priv,
- false
- );
- expect(decryptedByAlice).toBe(plaintext);
- });
- });
-});
diff --git a/apps/mobile/src/components/EncryptionKeyInitializer.tsx b/apps/mobile/src/components/EncryptionKeyInitializer.tsx
deleted file mode 100644
index e216cf2..0000000
--- a/apps/mobile/src/components/EncryptionKeyInitializer.tsx
+++ /dev/null
@@ -1,206 +0,0 @@
-/**
- * EncryptionKeyInitializer Component
- *
- * Handles automatic initialization and upload of E2EE encryption keys.
- * Should be placed in the app layout to run after authentication.
- *
- * Uses global Zustand store for key state to ensure all components
- * see the same keys at all times.
- */
-
-import { useEffect, useRef, useState } from 'react';
-import { Platform } from 'react-native';
-import { useEncryptionKeys } from '../hooks';
-import { useAuthStore } from '../store';
-import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
-import { triggerKeyRotation, getAdminPublicKey } from '../lib/encryption';
-import { LoadingOverlay } from './ui/LoadingOverlay';
-import { colors } from '../theme';
-
-/**
- * Check if crypto is available (react-native-quick-crypto polyfill installed)
- */
-function isCryptoAvailable(): boolean {
- return typeof crypto !== 'undefined' && crypto.subtle !== undefined;
-}
-
-/**
- * Component that initializes encryption keys after user authentication.
- *
- * Flow:
- * 1. When user signs in, check if they have encryption keys
- * 2. If not, generate new RSA key pair
- * 3. Upload public key to Supabase profile
- * 4. If user has a partner without keys, trigger re-encryption when partner generates keys
- */
-export function EncryptionKeyInitializer(): JSX.Element | null {
- const { initializeAndUploadKeys, isLoading: keysLoading, hasKeys, error } = useEncryptionKeys();
- const user = useAuthStore((s) => s.user);
- const couple = useAuthStore((s) => s.couple);
-
- // Track if keys were regenerated this session (new device, reinstall, etc.)
- const [wasRegenerated, setWasRegenerated] = useState(false);
- const hasTriggeredRotation = useRef(false);
- const hasLoggedCryptoWarning = useRef(false);
- const isInitializing = useRef(false);
- const hasInitialized = useRef(false);
-
- // Show loading overlay when we are initializing keys (generating new ones takes time)
- // or when rotation is in progress
- const [showLoading, setShowLoading] = useState(false);
- const [loadingStatus, setLoadingStatus] = useState('Connecting...');
-
- // Effect 1: Initialize encryption keys when user is authenticated
- useEffect(() => {
- // Skip on web - E2EE not supported
- if (Platform.OS === 'web') return;
-
- // Check if crypto is available (native modules rebuilt)
- if (!isCryptoAvailable()) {
- if (!hasLoggedCryptoWarning.current) {
- console.warn('[E2EE Init] Crypto not available. Run: npx expo prebuild --clean');
- hasLoggedCryptoWarning.current = true;
- }
- return;
- }
-
- // Skip if no user, already initialized, or currently initializing
- if (!user?.id || hasInitialized.current || isInitializing.current) return;
-
- // Wait for initial key loading to complete
- if (keysLoading) return;
-
- // Skip if keys already exist in global state
- if (hasKeys) {
- console.log('[E2EE Init] Keys already loaded in global state');
- hasInitialized.current = true;
- return;
- }
-
- // Skip if there was an error loading keys (avoid retry loop)
- if (error) {
- console.warn('[E2EE Init] Skipping key initialization due to previous error:', error.message);
- return;
- }
-
- const initKeys = async () => {
- try {
- // Mark as initializing to prevent re-entry
- isInitializing.current = true;
- // Only show loading if we are generating keys (which might take a second)
- // Standard loading is fast enough to not need a full screen blocker
-
- console.log('[E2EE Init] Starting key initialization...');
-
- // Initialize keys and upload in one operation
- const result = await initializeAndUploadKeys();
-
- console.log('[E2EE Init] Encryption keys initialized and uploaded');
-
- // Pre-fetch admin key to warm the cache before user reaches chat
- // This reduces the delay before secureReady becomes true
- try {
- await getAdminPublicKey();
- console.log('[E2EE Init] Admin key pre-fetched successfully');
- } catch (adminKeyError) {
- // Non-fatal: admin key will be fetched again when needed
- console.warn('[E2EE Init] Failed to pre-fetch admin key:', adminKeyError);
- }
-
- // Track if keys were regenerated (for rotation decision)
- if (result.wasRegenerated) {
- console.log('[E2EE Init] Keys were regenerated this session');
- setWasRegenerated(true);
- }
-
- hasInitialized.current = true;
- } catch (err) {
- console.error('[E2EE Init] Failed to initialize encryption keys:', err);
- // Reset initialization flag so we can retry on next render
- // But only if it's a transient error, not a crypto unavailable error
- if (err instanceof Error && !err.message.includes('Crypto')) {
- isInitializing.current = false;
- }
- setShowLoading(false);
- }
- };
-
- initKeys();
- }, [user?.id, keysLoading, hasKeys, error, initializeAndUploadKeys]);
-
- // Effect 2: Trigger key rotation when keys were regenerated and couple is available
- useEffect(() => {
- // Skip on web or if crypto not available
- if (Platform.OS === 'web' || !isCryptoAvailable()) return;
-
- // Need user and couple to trigger rotation
- if (!user?.id || !couple?.id) return;
-
- // Only trigger once per session
- if (hasTriggeredRotation.current) return;
-
- // Wait for keys to be initialized in global state
- if (!hasKeys) return;
-
- // Only trigger rotation if keys were actually regenerated this session
- // (new device, app reinstall, key corruption recovery, etc.)
- if (!wasRegenerated) {
- // If we're done initializing but not rotating, hide loading
- if (showLoading) {
- setShowLoading(false);
- isInitializing.current = false;
- }
- console.log('[E2EE Init] Keys unchanged, skipping rotation');
- return;
- }
-
- const runRotation = async () => {
- try {
- hasTriggeredRotation.current = true;
- // Only show loading for rotation which is a heavier operation
- setShowLoading(true);
- setLoadingStatus('Updating security...');
-
- // Trigger key rotation - re-wraps message keys for the new public key
- console.log('[E2EE Init] Keys were regenerated, triggering key rotation...');
- await triggerKeyRotation();
-
- // Trigger re-encryption of pending messages (for messages where
- // recipient had no key at send time)
- await reencryptPendingMessages({ force: true });
-
- console.log('[E2EE Init] Key rotation complete');
- } catch (err) {
- console.error('[E2EE Init] Failed to run key rotation:', err);
- // Reset so we can retry
- hasTriggeredRotation.current = false;
- } finally {
- setShowLoading(false);
- isInitializing.current = false;
- }
- };
-
- runRotation();
- }, [user?.id, couple?.id, hasKeys, wasRegenerated, showLoading]);
-
- // Reset initialization flags when user changes (sign out/sign in)
- useEffect(() => {
- if (!user?.id) {
- hasInitialized.current = false;
- setWasRegenerated(false);
- isInitializing.current = false;
- hasTriggeredRotation.current = false;
- setShowLoading(false);
- }
- }, [user?.id]);
-
- return (
-
- );
-}
diff --git a/apps/mobile/src/features/chat/ChatScreen.tsx b/apps/mobile/src/features/chat/ChatScreen.tsx
index d21e7e0..d89fcc0 100644
--- a/apps/mobile/src/features/chat/ChatScreen.tsx
+++ b/apps/mobile/src/features/chat/ChatScreen.tsx
@@ -16,7 +16,7 @@ import type { ReportReason } from "./types";
import { supabase } from "../../lib/supabase";
import { useAuthStore, useMessageStore } from "../../store";
-import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useEncryptedSend, useMediaSaver } from "../../hooks";
+import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useMediaSaver } from "../../hooks";
import { Database } from "../../types/supabase";
import { GradientBackground } from "../../components/ui";
import { colors, gradients, radius, spacing, typography } from "../../theme";
@@ -34,7 +34,7 @@ export const ChatScreen: React.FC = () => {
const matchId = id as string;
const router = useRouter();
const insets = useSafeAreaInsets();
- const { user, partner, encryptionKeys } = useAuthStore();
+ const { user, partner } = useAuthStore();
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
const [match, setMatch] = useState(null);
const [inputText, setInputText] = useState("");
@@ -74,23 +74,6 @@ export const ChatScreen: React.FC = () => {
onNewMessage: clearTypingIndicator,
});
- // 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({
@@ -196,35 +179,18 @@ export const ChatScreen: React.FC = () => {
const handleSend = async () => {
if (!inputText.trim()) return;
if (!user) return;
- if (!secureReady) return;
- if (isEncrypting) return;
const content = inputText.trim();
setInputText("");
try {
- // Encrypt the message (secure-only)
- console.log(`[E2EE Send] isE2EEAvailable=${isE2EEAvailable}`);
- const encryptedPayload = await encryptMessage(content);
- console.log(`[E2EE Send] encryptedPayload=`, {
- version: encryptedPayload.version,
- encrypted_content_length: encryptedPayload.encrypted_content.length,
- encryption_iv_length: encryptedPayload.encryption_iv.length,
- keys_metadata: encryptedPayload.keys_metadata,
- });
-
- // Send encrypted message (v2)
- const { error, data } = await supabase.from("messages").insert({
+ // Send plaintext message (v1) - protected by TLS in transit and RLS policies
+ const { error } = await supabase.from("messages").insert({
match_id: matchId,
user_id: user.id,
- content: null,
- version: encryptedPayload.version,
- encrypted_content: encryptedPayload.encrypted_content,
- encryption_iv: encryptedPayload.encryption_iv,
- keys_metadata: encryptedPayload.keys_metadata as unknown as Record,
- }).select();
-
- console.log(`[E2EE Send] Insert result:`, { error, data });
+ content: content,
+ version: 1,
+ });
if (error) {
Alert.alert("Error", "Failed to send message");
@@ -234,26 +200,12 @@ export const ChatScreen: React.FC = () => {
}
} catch (err) {
console.error("Error sending message:", err);
- // Show the actual error message if it's user-friendly (from encryption)
- const errorMessage = err instanceof Error && err.message
- ? err.message
- : "Failed to send message";
-
- // Map technical/unknown errors to friendly ones
- let friendlyMessage = errorMessage;
- if (errorMessage.includes('Failed to send') && !errorMessage.includes('connection')) {
- friendlyMessage = "Couldn't send secure message. Please check your connection.";
- } else if (errorMessage.includes('verification failed')) {
- friendlyMessage = "Security check failed. Please restart the app.";
- }
-
- Alert.alert("Delivery Failed", friendlyMessage);
+ Alert.alert("Delivery Failed", "Couldn't send message. Please check your connection.");
setInputText(content);
}
};
const handlePickMedia = async () => {
- if (!secureReady) return;
const result = await pickMedia();
if (result) {
uploadMedia(result.uri, result.mediaType);
@@ -261,7 +213,6 @@ export const ChatScreen: React.FC = () => {
};
const handleTakePhoto = async () => {
- if (!secureReady) return;
const result = await takePhoto();
if (result) {
uploadMedia(result.uri, 'image');
@@ -269,7 +220,6 @@ export const ChatScreen: React.FC = () => {
};
const handleRecordVideo = async () => {
- if (!secureReady) return;
const result = await recordVideo();
if (result) {
uploadMedia(result.uri, 'video');
@@ -335,13 +285,6 @@ export const ChatScreen: React.FC = () => {
onBack={() => router.push('/(app)/matches')}
/>
- {recoveryMessage && (
-
-
- {recoveryMessage}
-
-
- )}
{
{/* Top border glow */}
- {!secureReady && (
-
-
- Finishing security setup...
-
- )}
-
void;
onSend: () => void;
onPickMedia: () => void;
@@ -31,7 +30,6 @@ const ACCENT = colors.premium.gold;
export function InputBar({
inputText,
uploading,
- secureReady,
onChangeText,
onSend,
onPickMedia,
@@ -65,10 +63,8 @@ export function InputBar({
transform: [{ rotate: `${buttonRotation.value}deg` }],
}));
- const sendDisabled = !inputText.trim() || !secureReady;
- const mediaDisabled = uploading || !secureReady;
- // Determine if send is disabled specifically because of security (not empty input)
- const waitingForSecurity = !secureReady && inputText.trim().length > 0;
+ const sendDisabled = !inputText.trim();
+ const mediaDisabled = uploading;
return (
@@ -153,8 +149,7 @@ export function InputBar({
disabled={sendDisabled}
activeOpacity={0.8}
accessibilityRole="button"
- accessibilityLabel={waitingForSecurity ? "Send message - securing connection" : "Send message"}
- accessibilityHint={waitingForSecurity ? "Please wait while we secure your connection" : undefined}
+ accessibilityLabel="Send message"
testID="chat-input-send"
>
)}
diff --git a/apps/mobile/src/features/chat/components/MessageContent.tsx b/apps/mobile/src/features/chat/components/MessageContent.tsx
index cbbe95d..ce8013b 100644
--- a/apps/mobile/src/features/chat/components/MessageContent.tsx
+++ b/apps/mobile/src/features/chat/components/MessageContent.tsx
@@ -1,18 +1,16 @@
/**
* MessageContent - Renders message content (text, images, videos)
* Handles media reveal logic, expired media, and loading states.
- * Supports both v1 (plaintext) and v2 (E2EE encrypted) messages.
*/
-import React from 'react';
-import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator, Alert } from 'react-native';
+import React, { useState, useEffect } from 'react';
+import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme';
import { MessageMeta } from './MessageMeta';
import { ChatVideoPlayer } from './ChatVideoPlayer';
-import { useDecryptedMessage, useDecryptedMedia } from '../../../hooks';
-import type { KeysMetadata } from '../../../lib/encryption';
+import { supabase } from '../../../lib/supabase';
import type { Message } from '../types';
const ACCENT = colors.premium.gold;
@@ -52,61 +50,41 @@ export function MessageContent({
);
}
- // Use decryption hook for E2EE text messages
- const {
- content: decryptedContent,
- isDecrypting,
- error: decryptError,
- errorCode: decryptErrorCode,
- retry: retryDecrypt,
- } = useDecryptedMessage({
- message: {
- id: item.id,
- content: item.content,
- version: item.version,
- encrypted_content: item.encrypted_content,
- encryption_iv: item.encryption_iv,
- keys_metadata: item.keys_metadata as KeysMetadata | null,
- user_id: item.user_id,
- },
- currentUserId,
- });
-
const isVideo = (item as any).media_type === 'video';
const isViewed = !!item.media_viewed_at;
const isRecipientHidden = !isMe && !isViewed;
- // Use decryption hook for E2EE media (images/videos)
- const {
- uri: mediaUri,
- isDecrypting: isDecryptingMedia,
- error: mediaError,
- errorCode: mediaErrorCode,
- retry: retryMedia,
- isEncrypted: isMediaEncrypted,
- } = useDecryptedMedia({
- messageId: item.id,
- mediaPath: item.media_path ?? null,
- version: item.version,
- encryptionIv: item.encryption_iv,
- keysMetadata: item.keys_metadata as KeysMetadata | null,
- isMe,
- mediaType: isVideo ? 'video' : 'image',
- shouldFetch: !!item.media_path && !isRecipientHidden,
- });
+ // Get signed URL for media
+ const [mediaUri, setMediaUri] = useState(null);
+ const [mediaLoading, setMediaLoading] = useState(false);
+ const [mediaError, setMediaError] = useState(false);
- const isPendingMedia = mediaErrorCode === 'E2EE_PENDING_RECIPIENT_KEY';
+ useEffect(() => {
+ if (!item.media_path || isRecipientHidden) {
+ setMediaUri(null);
+ return;
+ }
- const showSecureMediaInfo = () => {
- Alert.alert(
- 'Secure media',
- "To keep your chats private, photos and videos are locked to each personβs secure key.\n\nThis media was sent before your secure key was ready, so itβs still locked. Weβre now securely updating the lock for your device (without exposing the media) so you can open it.\n\nIf it doesnβt unlock in a few seconds, tap βTry againβ.",
- [
- { text: 'OK', style: 'cancel' },
- { text: 'Try again', onPress: () => retryMedia() },
- ]
- );
- };
+ const fetchMediaUrl = async () => {
+ setMediaLoading(true);
+ setMediaError(false);
+ try {
+ const { data, error } = await supabase.storage
+ .from('chat-media')
+ .createSignedUrl(item.media_path!, 3600); // 1 hour expiry
+
+ if (error) throw error;
+ setMediaUri(data.signedUrl);
+ } catch (err) {
+ console.error('Failed to get media URL:', err);
+ setMediaError(true);
+ } finally {
+ setMediaLoading(false);
+ }
+ };
+
+ fetchMediaUrl();
+ }, [item.media_path, isRecipientHidden]);
// Handle expired videos
const isExpired = !!(item as any).media_expired;
@@ -135,8 +113,7 @@ export function MessageContent({
// Handle media (images and videos)
if (item.media_path || isExpired) {
const canOpenFullScreen = (isMe || isViewed) && !isVideo; // Only images can go fullscreen
- const hasMediaError = !!mediaError;
- const isLoading = isDecryptingMedia || (!mediaUri && !hasMediaError && !isRecipientHidden);
+ const isLoading = mediaLoading || (!mediaUri && !mediaError && !isRecipientHidden);
// Video content
if (isVideo) {
@@ -166,26 +143,11 @@ export function MessageContent({
Video hidden
Tap to reveal
- ) : isPendingMedia ? (
-
-
-
-
- Secure video
- Finishing security setup β’ Tap for info
- {isDecryptingMedia && }
-
-
- ) : mediaUri || hasMediaError ? (
+ ) : mediaUri || mediaError ? (
) : (
@@ -209,17 +171,13 @@ export function MessageContent({
return (
{
- if (isPendingMedia) {
- showSecureMediaInfo();
- return;
- }
if (canOpenFullScreen && mediaUri) {
onImagePress(mediaUri);
}
}}
- disabled={!(isPendingMedia || (canOpenFullScreen && !!mediaUri))}
+ disabled={!(canOpenFullScreen && !!mediaUri)}
>
{isRecipientHidden ? (
- ) : isPendingMedia ? (
-
-
-
- Secure photo
- Finishing security setup β’ Tap for info
- {isDecryptingMedia && }
-
- ) : hasMediaError ? (
+ ) : mediaError ? (
Failed to load image
@@ -288,73 +233,10 @@ export function MessageContent({
);
}
- // Text message
- const version = item.version ?? 1;
- const isV2 = version === 2;
-
- const showSecureMessageInfo = () => {
- Alert.alert(
- 'Secure message',
- "To keep your chats private, each message is locked to your deviceβs secure key.\n\nThis message was sent before your secure key was ready, so itβs still locked. Weβre now securely updating the lock for your device (without exposing the message content) so you can read it.\n\nIf it doesnβt unlock in a few seconds, tap βTry againβ.",
- [
- { text: 'OK', style: 'cancel' },
- { text: 'Try again', onPress: () => retryDecrypt() },
- ]
- );
- };
-
- const renderText = () => {
- if (decryptErrorCode === 'E2EE_PENDING_RECIPIENT_KEY') {
- return (
-
-
-
-
- Secure message
- {isDecrypting && }
-
- Finishing security setup β’ Tap for info
-
-
- );
- }
-
- if (isDecrypting) {
- return (
-
-
-
- );
- }
-
- if (decryptError) {
- return (
-
- Couldnβt open secure message β’ Tap to retry
-
- );
- }
-
- if (isV2) {
- return {decryptedContent ?? 'Secure message'};
- }
-
- return {item.content ?? 'Failed to load message'};
- };
-
+ // Text message - display content directly
return (
<>
- {renderText()}
+ {item.content ?? 'Message unavailable'}
>
);
@@ -376,61 +258,12 @@ const styles = StyleSheet.create({
fontSize: 16,
lineHeight: 22,
},
- securePlaceholder: {
- gap: spacing.xs,
- },
- securePlaceholderRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs,
- },
- securePlaceholderTitle: {
- color: colors.textSecondary,
- fontSize: 14,
- fontWeight: '600',
- },
- securePlaceholderSubtext: {
- color: colors.textTertiary,
- fontSize: 12,
- },
- decryptingContainer: {
- paddingVertical: spacing.sm,
- paddingHorizontal: spacing.md,
- justifyContent: 'center',
- alignItems: 'center',
- },
- decryptingText: {
- color: colors.textSecondary,
- fontSize: 11,
- marginTop: spacing.xs,
- },
- errorText: {
- color: colors.textSecondary,
- fontStyle: 'italic',
- },
messageImage: {
width: 200,
height: 200,
borderRadius: radius.md,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
- secureMediaPlaceholder: {
- justifyContent: 'center',
- alignItems: 'center',
- gap: spacing.sm,
- overflow: 'hidden',
- },
- secureMediaPlaceholderText: {
- color: colors.textSecondary,
- fontSize: 12,
- fontWeight: '600',
- },
- secureMediaPlaceholderSubtext: {
- color: colors.textTertiary,
- fontSize: 11,
- textAlign: 'center',
- paddingHorizontal: spacing.md,
- },
messageImageLoading: {
justifyContent: 'center',
alignItems: 'center',
diff --git a/apps/mobile/src/features/chat/hooks/useMediaUpload.ts b/apps/mobile/src/features/chat/hooks/useMediaUpload.ts
index 0ec1073..9b12531 100644
--- a/apps/mobile/src/features/chat/hooks/useMediaUpload.ts
+++ b/apps/mobile/src/features/chat/hooks/useMediaUpload.ts
@@ -1,4 +1,4 @@
-import { useRef, useState } from 'react';
+import { useState } from 'react';
import { Alert, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import { Video as VideoCompressor } from 'react-native-compressor';
@@ -7,32 +7,10 @@ import { decode } from 'base64-arraybuffer';
import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics';
import { UploadStatus } from '../types';
-import { useAuthStore } from '../../../store';
-import {
- encryptMediaFile,
- getAdminPublicKey,
- getAdminKeyId,
- MESSAGE_VERSION_E2EE,
- isValidPublicKeyJwk,
- sanitizePublicKeyJwk,
- verifyKeyPair,
-} from '../../../lib/encryption';
-import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
-import { useEncryptionKeys } from '../../../hooks';
export function useMediaUpload(matchId: string, userId: string | undefined) {
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState(null);
- const verifiedKeysVersionRef = useRef(null);
- const partnerRefreshRef = useRef(0);
- const PARTNER_REFRESH_TTL_MS = 2 * 60 * 1000;
-
- const refreshPartner = useAuthStore((s) => s.refreshPartner);
- const { publicKeyJwk, privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
-
- // Check if E2EE is available for media
- const isE2EEAvailable =
- Platform.OS !== 'web' && hasKeys && !keysLoading && !!publicKeyJwk && !!privateKeyJwk;
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
if (!userId) return;
@@ -40,17 +18,9 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
setUploadStatus({ mediaType, status: mediaType === 'video' ? 'compressing' : 'uploading', thumbnailUri: uri });
try {
- // Block media uploads until encryption is ready (secure-only)
- if (!isE2EEAvailable || Platform.OS === 'web') {
- const readyError = new Error('Media security not ready. Please check your connection.');
- console.warn('[E2EE Media Upload] E2EE not available, blocking upload');
- throw readyError;
- }
-
let fileUri = uri;
let fileBody;
let ext = mediaType === 'video' ? 'mp4' : 'jpg';
- let encryptedPayload: EncryptedMediaPayload;
// Compress videos on native platforms before upload
if (mediaType === 'video' && Platform.OS !== 'web') {
@@ -73,108 +43,6 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
}
}
- // CRITICAL: Read keys directly from global store at encryption time
- // This ensures we always use the most current keys, not potentially stale hook state
- const currentKeys = useAuthStore.getState().encryptionKeys;
-
- if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk || !currentKeys.privateKeyJwk) {
- console.warn('[E2EE Media Upload] Keys not fully available in global store at encryption time');
- throw new Error('Media security not ready. Please check your connection.');
- }
-
- const senderPublicKey = currentKeys.publicKeyJwk;
- const senderPrivateKey = currentKeys.privateKeyJwk;
-
- // SAFETY CHECK: Verify the key pair works together before encrypting
- if (verifiedKeysVersionRef.current !== currentKeys.keysVersion) {
- console.log(
- `[E2EE Media Upload] Verifying sender key pair (version ${currentKeys.keysVersion})...`
- );
- const isValidKeyPair = await verifyKeyPair(
- senderPublicKey as RSAPublicKeyJWK,
- senderPrivateKey
- );
-
- if (!isValidKeyPair) {
- console.error('[E2EE Media Upload] CRITICAL: Sender key pair verification failed!');
- throw new Error('Security check failed. Please restart the app.');
- }
-
- verifiedKeysVersionRef.current = currentKeys.keysVersion;
- console.log('[E2EE Media Upload] Sender key pair verified successfully');
- }
-
- console.log('[E2EE Media Upload] Using sender public key from global store, version:', currentKeys.keysVersion);
-
- // Refresh partner's profile only when key is missing/invalid or TTL expires
- const resolvePartnerProfile = async () => {
- const cachedPartner = useAuthStore.getState().partner;
- const cachedKey = cachedPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
- const hasValidKey = cachedKey ? isValidPublicKeyJwk(cachedKey) : false;
- const now = Date.now();
- const shouldRefresh = !hasValidKey || (now - partnerRefreshRef.current) > PARTNER_REFRESH_TTL_MS;
-
- if (!shouldRefresh) {
- return cachedPartner;
- }
-
- console.log('[E2EE Media Upload] Refreshing partner profile...');
- try {
- const freshPartner = await refreshPartner();
- partnerRefreshRef.current = Date.now();
- return freshPartner ?? useAuthStore.getState().partner;
- } catch (refreshError) {
- console.warn('[E2EE Media Upload] Partner refresh failed, using cached key:', refreshError);
- partnerRefreshRef.current = Date.now();
- return cachedPartner;
- }
- };
-
- const freshPartner = await resolvePartnerProfile();
-
- let adminPublicKey: RSAPublicKeyJWK;
- let adminKeyId: string;
-
- try {
- adminPublicKey = await getAdminPublicKey();
- adminKeyId = await getAdminKeyId();
-
- if (!isValidPublicKeyJwk(adminPublicKey)) {
- throw new Error('Admin public key is invalid');
- }
- } catch (adminKeyError) {
- console.error('[E2EE Media Upload] Failed to fetch admin key:', adminKeyError);
- throw new Error('Connection issue. Please try again.');
- }
-
- let partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
-
- // Validate and sanitize partner's public key
- if (partnerPublicKey) {
- if (!isValidPublicKeyJwk(partnerPublicKey)) {
- console.warn('[E2EE Media Upload] Partner public key is invalid, treating as unavailable');
- partnerPublicKey = null;
- } else {
- // Sanitize to remove trailing dots from base64 fields
- partnerPublicKey = sanitizePublicKeyJwk(partnerPublicKey);
- }
- }
-
- console.log('[E2EE Media Upload] Partner public key available:', !!partnerPublicKey);
-
- encryptedPayload = await encryptMediaFile(
- fileUri,
- senderPublicKey as RSAPublicKeyJWK,
- partnerPublicKey ?? null,
- adminPublicKey,
- adminKeyId
- );
-
- // Use the encrypted file for upload
- fileUri = encryptedPayload.encryptedFileUri;
- ext = 'enc'; // Encrypted files use .enc extension
- console.log('Media encrypted successfully');
-
if (Platform.OS === 'web') {
const response = await fetch(fileUri);
const blob = await response.blob();
@@ -194,11 +62,10 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
} else {
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
fileBody = decode(base64);
-
}
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
- const contentType = 'application/octet-stream';
+ const contentType = mediaType === 'video' ? 'video/mp4' : 'image/jpeg';
const { error: uploadError } = await supabase.storage
.from("chat-media")
@@ -209,16 +76,7 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
if (uploadError) throw uploadError;
- // Clean up encrypted temp file
- if (encryptedPayload && encryptedPayload.encryptedFileUri) {
- try {
- await FileSystem.deleteAsync(encryptedPayload.encryptedFileUri, { idempotent: true });
- } catch (cleanupError) {
- console.warn('Failed to clean up encrypted temp file:', cleanupError);
- }
- }
-
- // Store the message with encryption metadata
+ // Store the message (plaintext v1)
await supabase.from("messages").insert({
match_id: matchId,
user_id: userId,
@@ -226,9 +84,7 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
media_path: fileName,
media_type: mediaType,
media_expired: false,
- version: MESSAGE_VERSION_E2EE,
- encryption_iv: encryptedPayload.encryption_iv,
- keys_metadata: encryptedPayload.keys_metadata as unknown as Record,
+ version: 1,
});
Events.mediaUploaded();
diff --git a/apps/mobile/src/features/profile/ProfileScreen.tsx b/apps/mobile/src/features/profile/ProfileScreen.tsx
index d2c6bb7..76e7932 100644
--- a/apps/mobile/src/features/profile/ProfileScreen.tsx
+++ b/apps/mobile/src/features/profile/ProfileScreen.tsx
@@ -20,7 +20,6 @@ import { useAuthStore, useSubscriptionStore } from '../../store';
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
import { supabase } from '../../lib/supabase'; // Needed for debug reset
-import { clearKeys } from '../../lib/encryption';
// Hooks
import { useProfileSettings, useCoupleManagement } from './hooks';
@@ -294,29 +293,6 @@ export function ProfileScreen() {
}
}}
/>
-
-