# E2EE Implementation Plan for Sauci Chat ## Overview This document outlines the implementation of client-side **3-way End-to-End Encryption (E2EE)** for all chat content (text messages, images, and videos) in the Sauci mobile app. The design provides: - **Full encryption** of message content before it leaves the device - **Triple-wrapped keys** allowing decryption by sender, recipient, and admin - **Backwards compatibility** with existing plaintext (v1) messages - **Admin moderation capability** for reported content review - **Device change recovery** via admin re-encryption --- ## Encryption Architecture ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ Message Encryption Flow │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────────┐ │ │ │ Text Message │ │ Media (Img/Video)│ │ │ └──────┬───────┘ └────────┬─────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Generate Random AES-256-GCM Content Key │ │ │ └──────────────────────────┬───────────────────────┘ │ │ │ │ │ ┌───────────────────┼───────────────────┐ │ │ ▼ ▼ ▼ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Encrypt │ │ Encrypt │ │ Encrypt │ │ │ │ AES Key │ │ AES Key │ │ AES Key │ │ │ │ with │ │ with │ │ with │ │ │ │ SENDER │ │ RECIPIENT │ │ ADMIN │ │ │ │ RSA Pub │ │ RSA Pub │ │ RSA Pub │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ │ │ └──────────────────┼──────────────────┘ │ │ ▼ │ │ ┌─────────────────────────┐ │ │ │ keys_metadata JSONB │ │ │ │ { │ │ │ │ sender_wrapped_key, │ │ │ │ recipient_wrapped_key, │ │ │ │ admin_wrapped_key, │ │ │ │ admin_key_id │ │ │ │ } │ │ │ └─────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` --- ## Key Decisions | Aspect | Decision | Rationale | |--------|----------|-----------| | **Crypto Library** | `react-native-quick-crypto@^0.7.x` | Works with old architecture (`newArchEnabled: false`), native performance | | **Symmetric Cipher** | AES-256-GCM | Authenticated encryption, widely supported | | **Asymmetric Cipher** | RSA-OAEP (SHA-256) | Secure key wrapping, PKCS#1 v2.1 | | **RSA Key Size** | 2048-bit | Fits in SecureStore (~1.7KB), secure for 10+ years | | **Key Format** | JWK (JSON Web Key) | Required - v0.x doesn't support PKCS8 import for RSA-OAEP | | **Media Encryption** | Download-then-decrypt | Simpler than streaming, acceptable for compressed media | | **Admin Key Storage** | Environment variable | Edge function accessible | | **Key Rotation** | Archive old keys | Old messages remain decryptable | | **Missing Partner Key** | Encrypt sender+admin only | Re-encrypt when partner generates keys | | **Device Change** | Admin re-encryption | Recovers access when user gets new device | --- ## Device Change / Key Loss Handling When a user loses their phone and reinstalls on a new device: ### Problem - New key pair generated on new device - Old private key is lost (was in old device's SecureStore) - User would lose access to all E2EE messages ### Solution: Admin Re-encryption When a user's public key changes (detected via database trigger or Edge Function): 1. System detects `profiles.public_key_jwk` was updated 2. Edge Function `handle-key-rotation` is triggered 3. For each v2 message where user is sender or recipient: - Unwrap AES key using admin private key - Re-wrap AES key with user's new public key - Update `sender_wrapped_key` or `recipient_wrapped_key` 4. User regains access to all historical messages ### Security Consideration - Admin key becomes more powerful (can re-encrypt at will) - This is acceptable since admin already has decryption access by design - Audit logging recommended for accountability --- ## Database Schema Changes ### 1. Messages Table Updates Add columns to support encrypted messages: ```sql -- Migration: add_e2ee_support_to_messages -- Version indicator: 1 = plaintext (legacy), 2 = E2EE ALTER TABLE messages ADD COLUMN version INTEGER DEFAULT 1; -- Encrypted message content (base64 encoded ciphertext) ALTER TABLE messages ADD COLUMN encrypted_content TEXT; -- Initialization vector for AES-GCM (base64 encoded, 12 bytes) ALTER TABLE messages ADD COLUMN encryption_iv TEXT; -- Triple-wrapped encryption keys and metadata ALTER TABLE messages ADD COLUMN keys_metadata JSONB; -- Add comment for documentation COMMENT ON COLUMN messages.version IS 'Encryption version: 1 = plaintext (legacy), 2 = E2EE with triple-wrapped keys'; -- Constraint: v2 messages must have encryption fields ALTER TABLE messages ADD CONSTRAINT e2ee_fields_required CHECK (version = 1 OR (version = 2 AND encryption_iv IS NOT NULL AND keys_metadata IS NOT NULL)); ``` ### 2. Profiles Table Updates Store user's public key for E2EE: ```sql -- Migration: add_public_key_to_profiles -- RSA public key in JWK format ALTER TABLE profiles ADD COLUMN public_key_jwk JSONB; -- Index for efficient lookups when encrypting for partner CREATE INDEX idx_profiles_couple_id_public_key ON profiles(couple_id) WHERE public_key_jwk IS NOT NULL; ``` ### 3. Master Keys Table Store admin public keys for moderation access: ```sql -- Migration: create_master_keys_table CREATE TABLE master_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), key_name TEXT UNIQUE NOT NULL, public_key_jwk JSONB NOT NULL, created_at TIMESTAMPTZ DEFAULT now(), rotated_at TIMESTAMPTZ, is_active BOOLEAN DEFAULT true ); -- RLS: Only super admins can view/manage ALTER TABLE master_keys ENABLE ROW LEVEL SECURITY; CREATE POLICY "Super admins can view master keys" ON master_keys FOR SELECT TO authenticated USING (is_super_admin()); CREATE POLICY "Super admins can manage master keys" ON master_keys FOR ALL TO authenticated USING (is_super_admin()); -- Insert initial master key (public key only - private key in env) -- This will be done during deployment setup ``` ### 4. Keys Metadata Structure ```typescript interface KeysMetadata { // AES key encrypted with sender's RSA public key (base64) sender_wrapped_key: string; // AES key encrypted with recipient's RSA public key (base64) // Null/undefined if recipient hasn't generated keys yet recipient_wrapped_key?: string; // AES key encrypted with admin's RSA public key (base64) admin_wrapped_key: string; // UUID of the master_keys record used admin_key_id: string; // Algorithm identifiers algorithm: 'AES-256-GCM'; key_wrap_algorithm: 'RSA-OAEP-SHA256'; // True if recipient key needs to be added when they join pending_recipient?: boolean; } ``` --- ## Mobile App Implementation ### File Structure ``` apps/mobile/src/lib/encryption/ ├── index.ts # Public API exports ├── types.ts # TypeScript interfaces ├── constants.ts # Algorithm constants, key names ├── utils.ts # Base64/ArrayBuffer conversions ├── keyManager.ts # Key generation, SecureStore storage ├── crypto.ts # Low-level RSA/AES operations ├── messageEncryption.ts # Encrypt text messages ├── messageDecryption.ts # Decrypt text messages ├── mediaEncryption.ts # Encrypt images/videos ├── mediaDecryption.ts # Decrypt images/videos (download-first) └── adminKeys.ts # Fetch active admin public key apps/mobile/src/hooks/ ├── useEncryptionKeys.ts # Hook to access user's keypair └── useDecryptedMessage.ts # Hook for message decryption apps/mobile/src/features/chat/ ├── hooks/ │ └── useEncryptedMediaUpload.ts # Modified upload with encryption └── components/ └── EncryptedMessageContent.tsx # Version-aware message renderer ``` ### Key Management #### Key Generation (on first launch or couple join) ```typescript // keyManager.ts import { subtle, getRandomValues } from 'react-native-quick-crypto'; import * as SecureStore from 'expo-secure-store'; const PRIVATE_KEY_PREFIX = 'e2ee_private_key_chunk_'; const PUBLIC_KEY_UPLOADED = 'e2ee_public_key_uploaded'; export async function generateAndStoreKeyPair(): Promise { // Generate RSA-2048 key pair const keyPair = await subtle.generateKey( { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), // 65537 hash: 'SHA-256', }, true, // extractable ['encrypt', 'decrypt'] ); // Export keys as JWK const publicKeyJwk = await subtle.exportKey('jwk', keyPair.publicKey); const privateKeyJwk = await subtle.exportKey('jwk', keyPair.privateKey); // Store private key in SecureStore (chunked if needed) await storePrivateKey(privateKeyJwk); return publicKeyJwk; } async function storePrivateKey(privateKeyJwk: JsonWebKey): Promise { const json = JSON.stringify(privateKeyJwk); // SecureStore has 2KB limit, RSA-2048 JWK is ~1.7KB // Chunk to be safe const chunks = chunkString(json, 1900); await SecureStore.setItemAsync( `${PRIVATE_KEY_PREFIX}count`, chunks.length.toString() ); for (let i = 0; i < chunks.length; i++) { await SecureStore.setItemAsync( `${PRIVATE_KEY_PREFIX}${i}`, chunks[i], { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY } ); } } export async function getPrivateKey(): Promise { const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`); if (!countStr) return null; const count = parseInt(countStr, 10); const chunks: string[] = []; for (let i = 0; i < count; i++) { const chunk = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}${i}`); if (!chunk) return null; chunks.push(chunk); } return JSON.parse(chunks.join('')); } export async function hasKeyPair(): Promise { const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`); return countStr !== null; } export async function clearKeys(): Promise { const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`); if (!countStr) return; const count = parseInt(countStr, 10); for (let i = 0; i < count; i++) { await SecureStore.deleteItemAsync(`${PRIVATE_KEY_PREFIX}${i}`); } await SecureStore.deleteItemAsync(`${PRIVATE_KEY_PREFIX}count`); await SecureStore.deleteItemAsync(PUBLIC_KEY_UPLOADED); } ``` #### Key Upload to Profile ```typescript // Called after key generation export async function uploadPublicKey( userId: string, publicKeyJwk: JsonWebKey ): Promise { const { error } = await supabase .from('profiles') .update({ public_key_jwk: publicKeyJwk }) .eq('id', userId); if (error) throw error; await SecureStore.setItemAsync(PUBLIC_KEY_UPLOADED, 'true'); } ``` ### Text Message Encryption ```typescript // messageEncryption.ts import { subtle, getRandomValues } from 'react-native-quick-crypto'; import { arrayBufferToBase64 } from './utils'; import type { KeysMetadata, EncryptedMessagePayload } from './types'; export async function encryptTextMessage( plaintext: string, senderPublicKeyJwk: JsonWebKey, recipientPublicKeyJwk: JsonWebKey | null, adminPublicKeyJwk: JsonWebKey, adminKeyId: string ): Promise { // 1. Generate random AES-256 key const aesKey = await subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); // 2. Generate random 96-bit IV const iv = getRandomValues(new Uint8Array(12)); // 3. Encrypt plaintext const encoder = new TextEncoder(); const plaintextBuffer = encoder.encode(plaintext); const ciphertext = await subtle.encrypt( { name: 'AES-GCM', iv }, aesKey, plaintextBuffer ); // 4. Export AES key for wrapping const rawAesKey = await subtle.exportKey('raw', aesKey); // 5. Wrap AES key for each party const senderWrappedKey = await wrapKeyForRecipient(rawAesKey, senderPublicKeyJwk); const adminWrappedKey = await wrapKeyForRecipient(rawAesKey, adminPublicKeyJwk); let recipientWrappedKey: string | undefined; if (recipientPublicKeyJwk) { recipientWrappedKey = await wrapKeyForRecipient(rawAesKey, recipientPublicKeyJwk); } return { version: 2, encrypted_content: arrayBufferToBase64(ciphertext), encryption_iv: arrayBufferToBase64(iv), keys_metadata: { sender_wrapped_key: senderWrappedKey, recipient_wrapped_key: recipientWrappedKey, admin_wrapped_key: adminWrappedKey, admin_key_id: adminKeyId, algorithm: 'AES-256-GCM', key_wrap_algorithm: 'RSA-OAEP-SHA256', pending_recipient: !recipientPublicKeyJwk, }, }; } async function wrapKeyForRecipient( rawAesKey: ArrayBuffer, recipientPublicKeyJwk: JsonWebKey ): Promise { const rsaKey = await subtle.importKey( 'jwk', recipientPublicKeyJwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt'] ); const wrappedKey = await subtle.encrypt( { name: 'RSA-OAEP' }, rsaKey, rawAesKey ); return arrayBufferToBase64(wrappedKey); } ``` ### Text Message Decryption ```typescript // messageDecryption.ts import { subtle } from 'react-native-quick-crypto'; import { base64ToArrayBuffer } from './utils'; import type { KeysMetadata } from './types'; export async function decryptTextMessage( encryptedContent: string, encryptionIv: string, keysMetadata: KeysMetadata, privateKeyJwk: JsonWebKey, isRecipient: boolean ): Promise { // 1. Select the correct wrapped key const wrappedKeyBase64 = isRecipient ? keysMetadata.recipient_wrapped_key : keysMetadata.sender_wrapped_key; if (!wrappedKeyBase64) { throw new Error('No wrapped key available for decryption'); } // 2. Import private key const privateKey = await subtle.importKey( 'jwk', privateKeyJwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt'] ); // 3. Unwrap AES key const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64); const rawAesKey = await subtle.decrypt( { name: 'RSA-OAEP' }, privateKey, wrappedKey ); // 4. Import AES key const aesKey = await subtle.importKey( 'raw', rawAesKey, { name: 'AES-GCM' }, false, ['decrypt'] ); // 5. Decrypt content const iv = base64ToArrayBuffer(encryptionIv); const ciphertext = base64ToArrayBuffer(encryptedContent); const plaintext = await subtle.decrypt( { name: 'AES-GCM', iv }, aesKey, ciphertext ); return new TextDecoder().decode(plaintext); } ``` ### Media Encryption (Download-First Approach) ```typescript // mediaEncryption.ts import { subtle, getRandomValues } from 'react-native-quick-crypto'; import * as FileSystem from 'expo-file-system'; import { arrayBufferToBase64, base64ToArrayBuffer } from './utils'; import type { KeysMetadata, EncryptedMediaPayload } from './types'; export async function encryptMediaFile( sourceUri: string, senderPublicKeyJwk: JsonWebKey, recipientPublicKeyJwk: JsonWebKey | null, adminPublicKeyJwk: JsonWebKey, adminKeyId: string ): Promise { // 1. Read entire file into memory const fileBase64 = await FileSystem.readAsStringAsync(sourceUri, { encoding: FileSystem.EncodingType.Base64, }); const fileBuffer = base64ToArrayBuffer(fileBase64); // 2. Generate AES key and IV const aesKey = await subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); const iv = getRandomValues(new Uint8Array(12)); // 3. Encrypt entire file const encryptedData = await subtle.encrypt( { name: 'AES-GCM', iv }, aesKey, fileBuffer ); // 4. Write encrypted file to temp location const encryptedUri = `${FileSystem.cacheDirectory}${Date.now()}.enc`; await FileSystem.writeAsStringAsync( encryptedUri, arrayBufferToBase64(encryptedData), { encoding: FileSystem.EncodingType.Base64 } ); // 5. Wrap AES key for all parties const rawAesKey = await subtle.exportKey('raw', aesKey); const senderWrappedKey = await wrapKeyForRecipient(rawAesKey, senderPublicKeyJwk); const adminWrappedKey = await wrapKeyForRecipient(rawAesKey, adminPublicKeyJwk); let recipientWrappedKey: string | undefined; if (recipientPublicKeyJwk) { recipientWrappedKey = await wrapKeyForRecipient(rawAesKey, recipientPublicKeyJwk); } return { encryptedFileUri: encryptedUri, encryption_iv: arrayBufferToBase64(iv), keys_metadata: { sender_wrapped_key: senderWrappedKey, recipient_wrapped_key: recipientWrappedKey, admin_wrapped_key: adminWrappedKey, admin_key_id: adminKeyId, algorithm: 'AES-256-GCM', key_wrap_algorithm: 'RSA-OAEP-SHA256', pending_recipient: !recipientPublicKeyJwk, }, }; } async function wrapKeyForRecipient( rawAesKey: ArrayBuffer, recipientPublicKeyJwk: JsonWebKey ): Promise { const rsaKey = await subtle.importKey( 'jwk', recipientPublicKeyJwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt'] ); const wrappedKey = await subtle.encrypt( { name: 'RSA-OAEP' }, rsaKey, rawAesKey ); return arrayBufferToBase64(wrappedKey); } ``` ### Media Decryption (Download-First Approach) ```typescript // mediaDecryption.ts import { subtle } from 'react-native-quick-crypto'; import * as FileSystem from 'expo-file-system'; import { base64ToArrayBuffer, arrayBufferToBase64 } from './utils'; import type { KeysMetadata } from './types'; export async function decryptMediaFile( encryptedUrl: string, // Signed URL to .enc file encryptionIv: string, keysMetadata: KeysMetadata, privateKeyJwk: JsonWebKey, isRecipient: boolean, mediaType: 'image' | 'video' ): Promise { // Returns local file URI // 1. Download entire encrypted file const tempEncryptedPath = `${FileSystem.cacheDirectory}${Date.now()}_encrypted.enc`; const downloadResult = await FileSystem.downloadAsync( encryptedUrl, tempEncryptedPath ); if (downloadResult.status !== 200) { throw new Error(`Failed to download encrypted file: ${downloadResult.status}`); } // 2. Read encrypted data const encryptedBase64 = await FileSystem.readAsStringAsync( downloadResult.uri, { encoding: FileSystem.EncodingType.Base64 } ); const encryptedData = base64ToArrayBuffer(encryptedBase64); // 3. Unwrap AES key const wrappedKeyBase64 = isRecipient ? keysMetadata.recipient_wrapped_key : keysMetadata.sender_wrapped_key; if (!wrappedKeyBase64) { throw new Error('No wrapped key available'); } const privateKey = await subtle.importKey( 'jwk', privateKeyJwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt'] ); const rawAesKey = await subtle.decrypt( { name: 'RSA-OAEP' }, privateKey, base64ToArrayBuffer(wrappedKeyBase64) ); const aesKey = await subtle.importKey( 'raw', rawAesKey, { name: 'AES-GCM' }, false, ['decrypt'] ); // 4. Decrypt file const iv = base64ToArrayBuffer(encryptionIv); const decryptedData = await subtle.decrypt( { name: 'AES-GCM', iv }, aesKey, encryptedData ); // 5. Write decrypted file const ext = mediaType === 'video' ? 'mp4' : 'jpg'; const decryptedUri = `${FileSystem.cacheDirectory}${Date.now()}_decrypted.${ext}`; await FileSystem.writeAsStringAsync( decryptedUri, arrayBufferToBase64(decryptedData), { encoding: FileSystem.EncodingType.Base64 } ); // 6. Clean up encrypted temp file await FileSystem.deleteAsync(downloadResult.uri, { idempotent: true }); return decryptedUri; } ``` ### Backwards Compatible Message Hook ```typescript // hooks/useDecryptedMessage.ts import { useState, useEffect } from 'react'; import { decryptTextMessage } from '../lib/encryption/messageDecryption'; import { useEncryptionKeys } from './useEncryptionKeys'; import type { Message } from '../features/chat/types'; interface DecryptedMessageState { content: string | null; mediaUri: string | null; isDecrypting: boolean; error: Error | null; } export function useDecryptedMessage( message: Message, isMe: boolean ): DecryptedMessageState { const [state, setState] = useState({ content: null, mediaUri: null, isDecrypting: false, error: null, }); const { privateKeyJwk } = useEncryptionKeys(); useEffect(() => { async function decrypt() { // Version 1 or undefined: Legacy plaintext if (!message.version || message.version === 1) { setState({ content: message.content, mediaUri: null, // Handle via existing signed URL flow isDecrypting: false, error: null, }); return; } // Version 2: E2EE - requires decryption if (message.version === 2) { if (!privateKeyJwk) { setState({ content: null, mediaUri: null, isDecrypting: false, error: new Error('Encryption keys not available'), }); return; } setState(prev => ({ ...prev, isDecrypting: true })); try { let content: string | null = null; if (message.encrypted_content) { content = await decryptTextMessage( message.encrypted_content, message.encryption_iv!, message.keys_metadata!, privateKeyJwk, !isMe ); } setState({ content, mediaUri: null, // Media handled separately via useDecryptedMedia isDecrypting: false, error: null, }); } catch (err) { console.error('Failed to decrypt message:', err); setState({ content: '[Unable to decrypt]', mediaUri: null, isDecrypting: false, error: err as Error, }); } } } decrypt(); }, [message.id, message.version, privateKeyJwk, isMe]); return state; } ``` ### Encryption Keys Hook ```typescript // hooks/useEncryptionKeys.ts import { useState, useEffect } from 'react'; import { getPrivateKey, hasKeyPair } from '../lib/encryption/keyManager'; interface EncryptionKeysState { privateKeyJwk: JsonWebKey | null; isLoading: boolean; hasKeys: boolean; } export function useEncryptionKeys(): EncryptionKeysState { const [state, setState] = useState({ privateKeyJwk: null, isLoading: true, hasKeys: false, }); useEffect(() => { async function loadKeys() { try { const hasKeys = await hasKeyPair(); if (hasKeys) { const privateKey = await getPrivateKey(); setState({ privateKeyJwk: privateKey, isLoading: false, hasKeys: true, }); } else { setState({ privateKeyJwk: null, isLoading: false, hasKeys: false, }); } } catch (error) { console.error('Failed to load encryption keys:', error); setState({ privateKeyJwk: null, isLoading: false, hasKeys: false, }); } } loadKeys(); }, []); return state; } ``` --- ## Edge Functions ### Pending Recipient Re-encryption When a partner generates their keys after messages were already sent: ```typescript // supabase/functions/reencrypt-pending-messages/index.ts import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "jsr:@supabase/supabase-js@2"; const supabaseAdmin = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ); Deno.serve(async (req: Request) => { try { const { couple_id, new_recipient_user_id, new_recipient_public_key_jwk } = await req.json(); // Get admin private key from environment const adminPrivateKeyJwk = JSON.parse( Deno.env.get('ADMIN_PRIVATE_KEY_JWK')! ); // Find messages with pending_recipient = true for this couple const { data: matches } = await supabaseAdmin .from('matches') .select('id') .eq('couple_id', couple_id); const matchIds = matches?.map(m => m.id) || []; const { data: messages } = await supabaseAdmin .from('messages') .select('id, user_id, keys_metadata') .eq('version', 2) .in('match_id', matchIds); let updatedCount = 0; for (const message of messages || []) { const metadata = message.keys_metadata; // Only process if pending_recipient OR missing recipient key for this user if (!metadata.pending_recipient && metadata.recipient_wrapped_key) { continue; } // Unwrap AES key using admin key const rawAesKey = await unwrapWithAdminKey( metadata.admin_wrapped_key, adminPrivateKeyJwk ); // Re-wrap for new recipient const recipientWrappedKey = await wrapForRecipient( rawAesKey, new_recipient_public_key_jwk ); // Update message await supabaseAdmin .from('messages') .update({ keys_metadata: { ...metadata, recipient_wrapped_key: recipientWrappedKey, pending_recipient: false, } }) .eq('id', message.id); updatedCount++; } return new Response( JSON.stringify({ success: true, updated: updatedCount }), { headers: { 'Content-Type': 'application/json' } } ); } catch (error) { console.error('Error re-encrypting messages:', error); return new Response( JSON.stringify({ success: false, error: error.message }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } }); async function unwrapWithAdminKey( wrappedKeyBase64: string, adminPrivateKeyJwk: JsonWebKey ): Promise { const privateKey = await crypto.subtle.importKey( 'jwk', adminPrivateKeyJwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt'] ); const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64); return await crypto.subtle.decrypt( { name: 'RSA-OAEP' }, privateKey, wrappedKey ); } async function wrapForRecipient( rawAesKey: ArrayBuffer, recipientPublicKeyJwk: JsonWebKey ): Promise { const publicKey = await crypto.subtle.importKey( 'jwk', recipientPublicKeyJwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt'] ); const wrappedKey = await crypto.subtle.encrypt( { name: 'RSA-OAEP' }, publicKey, rawAesKey ); return arrayBufferToBase64(wrappedKey); } function base64ToArrayBuffer(base64: string): ArrayBuffer { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } function arrayBufferToBase64(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } ``` ### Handle Key Rotation (Device Change) When a user's public key changes (new device): ```typescript // supabase/functions/handle-key-rotation/index.ts import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "jsr:@supabase/supabase-js@2"; const supabaseAdmin = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ); Deno.serve(async (req: Request) => { try { const { user_id, new_public_key_jwk } = await req.json(); // Get admin private key from environment const adminPrivateKeyJwk = JSON.parse( Deno.env.get('ADMIN_PRIVATE_KEY_JWK')! ); // Get user's couple_id const { data: profile } = await supabaseAdmin .from('profiles') .select('couple_id') .eq('id', user_id) .single(); if (!profile?.couple_id) { return new Response( JSON.stringify({ success: true, updated: 0, reason: 'No couple' }), { headers: { 'Content-Type': 'application/json' } } ); } // Find all matches for this couple const { data: matches } = await supabaseAdmin .from('matches') .select('id') .eq('couple_id', profile.couple_id); const matchIds = matches?.map(m => m.id) || []; // Find all v2 messages in these matches const { data: messages } = await supabaseAdmin .from('messages') .select('id, user_id, keys_metadata') .eq('version', 2) .in('match_id', matchIds); let updatedCount = 0; for (const message of messages || []) { const metadata = message.keys_metadata; const isSender = message.user_id === user_id; // Unwrap AES key using admin key const rawAesKey = await unwrapWithAdminKey( metadata.admin_wrapped_key, adminPrivateKeyJwk ); // Re-wrap with new public key const newWrappedKey = await wrapForRecipient( rawAesKey, new_public_key_jwk ); // Update the appropriate field const updatedMetadata = { ...metadata }; if (isSender) { updatedMetadata.sender_wrapped_key = newWrappedKey; } else { updatedMetadata.recipient_wrapped_key = newWrappedKey; updatedMetadata.pending_recipient = false; } await supabaseAdmin .from('messages') .update({ keys_metadata: updatedMetadata }) .eq('id', message.id); updatedCount++; } return new Response( JSON.stringify({ success: true, updated: updatedCount }), { headers: { 'Content-Type': 'application/json' } } ); } catch (error) { console.error('Error handling key rotation:', error); return new Response( JSON.stringify({ success: false, error: error.message }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } }); // ... include unwrapWithAdminKey, wrapForRecipient, base64 utils from above ``` --- ## Admin Moderation Utility ```typescript // Admin dashboard utility or Edge Function export async function decryptForModeration( messageId: string, adminPrivateKeyJwk: JsonWebKey ): Promise { // 1. Fetch message with admin privileges const { data: message } = await supabaseAdmin .from('messages') .select('*') .eq('id', messageId) .single(); if (!message) { throw new Error('Message not found'); } // 2. Version 1: Return plaintext directly if (!message.version || message.version === 1) { return { content: message.content, mediaUrl: message.media_path ? await getAdminSignedUrl(message.media_path) : null, }; } // 3. Version 2: Decrypt with admin key const { admin_wrapped_key, admin_key_id } = message.keys_metadata; // Verify master key exists const { data: masterKey } = await supabaseAdmin .from('master_keys') .select('id') .eq('id', admin_key_id) .single(); if (!masterKey) { throw new Error(`Master key ${admin_key_id} not found - may have been rotated`); } // Import admin private key const privateKey = await crypto.subtle.importKey( 'jwk', adminPrivateKeyJwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt'] ); // Unwrap AES key const rawAesKey = await crypto.subtle.decrypt( { name: 'RSA-OAEP' }, privateKey, base64ToArrayBuffer(admin_wrapped_key) ); // Import AES key const aesKey = await crypto.subtle.importKey( 'raw', rawAesKey, { name: 'AES-GCM' }, false, ['decrypt'] ); // Decrypt text content let content: string | null = null; if (message.encrypted_content) { const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: base64ToArrayBuffer(message.encryption_iv) }, aesKey, base64ToArrayBuffer(message.encrypted_content) ); content = new TextDecoder().decode(plaintext); } // For media: download .enc file and decrypt similarly let mediaUrl: string | null = null; if (message.media_path && !message.media_expired) { // Implementation would download and decrypt the .enc file // Return a temporary signed URL or blob URL } return { content, mediaUrl }; } async function getAdminSignedUrl(mediaPath: string): Promise { const { data } = await supabaseAdmin.storage .from('chat-media') .createSignedUrl(mediaPath, 3600); return data?.signedUrl || ''; } ``` --- ## Implementation Tasks ### Phase 1: Database & Infrastructure | Task | Priority | Status | |------|----------|--------| | 1.1 Create migration: Add E2EE columns to `messages` table | High | ✅ | | 1.2 Create migration: Add `public_key_jwk` to `profiles` table | High | ✅ | | 1.3 Create migration: Create `master_keys` table with RLS policies | High | ✅ | | 1.4 Generate master admin RSA-2048 key pair | High | ⬜ | | 1.5 Insert master public key into `master_keys` table | High | ⬜ | | 1.6 Store master private key in Supabase Edge Function secrets | High | ⬜ | | 1.7 Update TypeScript types (`supabase.ts`) with new columns | High | ✅ | ### Phase 2: Crypto Library Integration | Task | Priority | Status | |------|----------|--------| | 2.1 Install `react-native-quick-crypto@^0.7.x` | High | ✅ | | 2.2 Run `npx expo prebuild --clean` for iOS and Android | High | ⬜ | | 2.3 Update `metro.config.js` to resolve `crypto` module | High | ✅ | | 2.4 Create `src/lib/encryption/` directory structure | High | ✅ | | 2.5 Implement utility functions (base64, ArrayBuffer) | High | ✅ | | 2.6 Test basic crypto operations work on device | High | ⬜ | ### Phase 3: Key Management | Task | Priority | Status | |------|----------|--------| | 3.1 Implement `keyManager.ts` with key generation | High | ✅ | | 3.2 Implement SecureStore chunked storage for private keys | High | ✅ | | 3.3 Implement public key upload to Supabase profile | High | ✅ | | 3.4 Create `useEncryptionKeys` hook | High | ✅ | | 3.5 Add key initialization to app startup (auth flow) | High | ✅ | | 3.6 Handle key generation when joining a couple | High | ✅ | ### Phase 4: Text Message Encryption | Task | Priority | Status | |------|----------|--------| | 4.1 Implement `messageEncryption.ts` | High | ✅ | | 4.2 Implement `messageDecryption.ts` | High | ✅ | | 4.3 Implement `adminKeys.ts` (fetch active admin public key) | High | ✅ | | 4.4 Update `ChatScreen.tsx` `handleSend` to encrypt text | High | ✅ | | 4.5 Create `useDecryptedMessage` hook | High | ✅ | | 4.6 Update message insert to include encryption fields | High | ✅ | ### Phase 5: Media Encryption | Task | Priority | Status | |------|----------|--------| | 5.1 Implement `mediaEncryption.ts` (full file approach) | High | ✅ | | 5.2 Implement `mediaDecryption.ts` (download-first) | High | ✅ | | 5.3 Update `useMediaUpload.ts` to encrypt before upload | High | ✅ | | 5.4 Update filename to use `.enc` extension | High | ✅ | | 5.5 Update `MessageContent.tsx` for encrypted media handling | High | ✅ | | 5.6 Implement decrypted media caching (avoid re-decrypting) | Medium | ✅ | ### Phase 6: Backwards Compatibility | Task | Priority | Status | |------|----------|--------| | 6.1 Update `MessageContent.tsx` to check `version` field | High | ✅ | | 6.2 Render v1 messages with existing plaintext flow | High | ✅ | | 6.3 Render v2 messages with decryption flow | High | ✅ | | 6.4 Add loading state while decrypting | Medium | ✅ | | 6.5 Add error state for decryption failures | Medium | ✅ | | 6.6 Test mixed v1/v2 message thread | High | ⬜ | ### Phase 7: Pending Recipient & Key Rotation | Task | Priority | Status | |------|----------|--------| | 7.1 Handle sending when partner has no public key | Medium | ✅ | | 7.2 Create `reencrypt-pending-messages` Edge Function | Medium | ✅ | | 7.3 Trigger re-encryption when partner generates keys | Medium | ✅ | | 7.4 Create `handle-key-rotation` Edge Function | Medium | ✅ | | 7.5 Trigger key rotation when public_key_jwk changes | Medium | ⬜ | | 7.6 Test end-to-end flow with new device | High | ⬜ | ### Phase 8: Admin Moderation | Task | Priority | Status | |------|----------|--------| | 8.1 Implement `decryptForModeration` utility | Medium | ⬜ | | 8.2 Create admin UI for message decryption (if needed) | Low | ⬜ | | 8.3 Add audit logging for admin decryptions | Medium | ⬜ | | 8.4 Test admin decryption of v1 and v2 messages | Medium | ⬜ | ### Phase 9: Testing & Polish | Task | Priority | Status | |------|----------|--------| | 9.1 Unit tests for encryption/decryption functions | High | ⬜ | | 9.2 Unit tests for key management | High | ⬜ | | 9.3 Integration test: Send and receive encrypted text | High | ⬜ | | 9.4 Integration test: Send and receive encrypted image | High | ⬜ | | 9.5 Integration test: Send and receive encrypted video | High | ⬜ | | 9.6 Test on low-end Android device (memory) | Medium | ⬜ | | 9.7 Test key persistence across app restarts | High | ⬜ | | 9.8 Test backward compatibility with existing messages | High | ⬜ | | 9.9 Performance testing (encryption time for large videos) | Medium | ⬜ | ### Phase 10: Documentation & Deployment | Task | Priority | Status | |------|----------|--------| | 10.1 Update CLAUDE.md with E2EE architecture notes | Medium | ⬜ | | 10.2 Document key rotation procedure | Medium | ⬜ | | 10.3 Document admin moderation procedure | Medium | ⬜ | | 10.4 Create deployment checklist | Medium | ⬜ | | 10.5 Deploy migrations to non-prod first | High | ⬜ | | 10.6 Test on non-prod thoroughly | High | ⬜ | | 10.7 Deploy to production | High | ⬜ | --- ## Utility Functions ```typescript // utils.ts export function arrayBufferToBase64(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } export function base64ToArrayBuffer(base64: string): ArrayBuffer { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } export function chunkString(str: string, size: number): string[] { const chunks: string[] = []; for (let i = 0; i < str.length; i += size) { chunks.push(str.slice(i, i + size)); } return chunks; } ``` --- ## TypeScript Types ```typescript // types.ts export interface KeysMetadata { sender_wrapped_key: string; recipient_wrapped_key?: string; admin_wrapped_key: string; admin_key_id: string; algorithm: 'AES-256-GCM'; key_wrap_algorithm: 'RSA-OAEP-SHA256'; pending_recipient?: boolean; } export interface EncryptedMessagePayload { version: 2; encrypted_content: string; encryption_iv: string; keys_metadata: KeysMetadata; } export interface EncryptedMediaPayload { encryptedFileUri: string; encryption_iv: string; keys_metadata: KeysMetadata; } export interface DecryptedContent { content: string | null; mediaUrl: string | null; } export interface DecryptedMessageState { content: string | null; mediaUri: string | null; isDecrypting: boolean; error: Error | null; } ``` --- ## Security Considerations ### Key Storage - Private keys stored in iOS Keychain / Android Keystore via SecureStore - Keys accessible only when device is unlocked (`WHEN_UNLOCKED_THIS_DEVICE_ONLY`) - Private keys never transmitted (except admin key stored in env) ### Device Change Recovery - When user gets new device, old private key is lost - Admin key used to re-wrap all message AES keys with new public key - User regains access to all historical messages automatically ### Admin Access - Admin can decrypt any message for moderation purposes - Admin private key stored securely in environment variable - Audit logging recommended for accountability ### Key Rotation (Admin) - Old master keys archived (remain in `master_keys` table with `is_active: false`) - Messages encrypted with old admin keys remain decryptable - New messages use active master key ### Couple Breakup - Both users retain their private keys - Both can still decrypt their own sent/received messages - No special handling required --- ## Future Enhancements 1. **Passphrase-based Key Backup**: Allow users to backup their private key encrypted with a passphrase to iCloud/Google Drive for manual recovery. 2. **Key Fingerprint Verification**: Show key fingerprints so partners can verify each other's identity out-of-band. 3. **Message Expiry**: Auto-delete decryption keys after a certain period for enhanced privacy. 4. **Multi-device Support**: Sync private keys across user's devices (requires secure key transfer protocol). --- ## Appendix: Admin Key Generation Script Run this once to generate the initial admin RSA key pair: ```javascript // scripts/generate-admin-keys.js // Run with: node scripts/generate-admin-keys.js const crypto = require('crypto'); async function generateAdminKeyPair() { const { publicKey, privateKey } = await crypto.subtle.generateKey( { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, true, ['encrypt', 'decrypt'] ); const publicKeyJwk = await crypto.subtle.exportKey('jwk', publicKey); const privateKeyJwk = await crypto.subtle.exportKey('jwk', privateKey); console.log('=== PUBLIC KEY (store in master_keys table) ==='); console.log(JSON.stringify(publicKeyJwk, null, 2)); console.log('\n=== PRIVATE KEY (store in ADMIN_PRIVATE_KEY_JWK env var) ==='); console.log(JSON.stringify(privateKeyJwk)); } generateAdminKeyPair().catch(console.error); ``` **Important:** Keep the private key secure. Never commit it to version control.