Files
Sauci/docs/e2ee-implementation.md
T

44 KiB

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:

-- 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:

-- 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:

-- 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

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)

// 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<JsonWebKey> {
  // 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<void> {
  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<JsonWebKey | null> {
  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<boolean> {
  const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`);
  return countStr !== null;
}

export async function clearKeys(): Promise<void> {
  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

// Called after key generation
export async function uploadPublicKey(
  userId: string, 
  publicKeyJwk: JsonWebKey
): Promise<void> {
  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

// 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<EncryptedMessagePayload> {
  
  // 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<string> {
  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

// 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<string> {
  
  // 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)

// 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<EncryptedMediaPayload> {
  
  // 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<string> {
  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)

// 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<string> {  // 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

// 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<DecryptedMessageState>({
    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

// 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<EncryptionKeysState>({
    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:

// 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<ArrayBuffer> {
  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<string> {
  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):

// 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

// Admin dashboard utility or Edge Function

export async function decryptForModeration(
  messageId: string,
  adminPrivateKeyJwk: JsonWebKey
): Promise<DecryptedContent> {
  
  // 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<string> {
  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

// 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

// 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:

// 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.