Implement E2EE and message content moderation

This commit is contained in:
Jamie Brunton committed 2026-01-02 20:49:11 +00:00
1 parent e1ff87dc03
commit 198cd80dfd
74 files changed
+12939 -2178

No files matched your search

@@ -0,0 +1,88 @@
/**
* Admin Keys Management
*
* Fetches the active admin public key from the master_keys table.
*/
import { supabase } from '../supabase';
import type { MasterKey, RSAPublicKeyJWK } from './types';
// Cache for admin key to avoid repeated database calls
let cachedAdminKey: MasterKey | null = null;
let cacheTimestamp: number = 0;
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Fetch the active admin public key from the database
*
* Results are cached for 5 minutes to reduce database calls.
*
* @param forceRefresh - Force a fresh fetch, bypassing cache
* @returns The active master key record
*/
export async function getActiveMasterKey(forceRefresh = false): Promise<MasterKey> {
const now = Date.now();
// Return cached key if still valid
if (!forceRefresh && cachedAdminKey && (now - cacheTimestamp) < CACHE_TTL_MS) {
return cachedAdminKey;
}
const { data, error } = await supabase
.from('master_keys')
.select('*')
.eq('is_active', true)
.limit(1)
.maybeSingle();
if (error) {
throw new Error(`Failed to fetch admin key: ${error.message}`);
}
if (!data) {
throw new Error('No active admin key found. E2EE is not configured.');
}
// Validate the key structure
const publicKeyJwk = data.public_key_jwk as RSAPublicKeyJWK;
if (!publicKeyJwk || publicKeyJwk.kty !== 'RSA') {
throw new Error('Invalid admin public key format');
}
cachedAdminKey = {
id: data.id,
key_name: data.key_name,
public_key_jwk: publicKeyJwk,
is_active: data.is_active ?? true,
created_at: data.created_at ?? '',
rotated_at: data.rotated_at,
};
cacheTimestamp = now;
return cachedAdminKey;
}
/**
* Get the admin public key JWK directly
*/
export async function getAdminPublicKey(): Promise<RSAPublicKeyJWK> {
const masterKey = await getActiveMasterKey();
return masterKey.public_key_jwk;
}
/**
* Get the admin key ID for storing in message metadata
*/
export async function getAdminKeyId(): Promise<string> {
const masterKey = await getActiveMasterKey();
return masterKey.id;
}
/**
* Clear the admin key cache
* Called when keys might have been rotated
*/
export function clearAdminKeyCache(): void {
cachedAdminKey = null;
cacheTimestamp = 0;
}
@@ -0,0 +1,30 @@
/**
* E2EE Constants
*
* Algorithm identifiers and storage key names for encryption
*/
// SecureStore key names for private key storage
export const PRIVATE_KEY_PREFIX = 'e2ee_private_key_chunk_';
export const PUBLIC_KEY_UPLOADED = 'e2ee_public_key_uploaded';
export const LOCAL_PUBLIC_KEY = 'e2ee_local_public_key';
// Encryption algorithms
export const SYMMETRIC_ALGORITHM = 'AES-GCM';
export const SYMMETRIC_KEY_LENGTH = 256;
export const IV_LENGTH_BYTES = 12; // 96 bits for AES-GCM
// Key wrapping
export const KEY_WRAP_ALGORITHM = 'RSA-OAEP';
export const KEY_WRAP_HASH = 'SHA-256';
export const RSA_KEY_SIZE = 2048;
// SecureStore chunking (RSA-2048 JWK is ~1.7KB, SecureStore limit is 2KB)
export const SECURE_STORE_CHUNK_SIZE = 1900;
// Default master key name
export const DEFAULT_MASTER_KEY_NAME = 'e2ee_admin_key_v1';
// Message version constants
export const MESSAGE_VERSION_PLAINTEXT = 1;
export const MESSAGE_VERSION_E2EE = 2;
+171
View File
@@ -0,0 +1,171 @@
/**
* Low-level Crypto Operations
*
* AES-256-GCM encryption/decryption and RSA key wrapping operations.
*/
import {
SYMMETRIC_ALGORITHM,
SYMMETRIC_KEY_LENGTH,
IV_LENGTH_BYTES,
KEY_WRAP_ALGORITHM
} from './constants';
import { arrayBufferToBase64, base64ToArrayBuffer } from './utils';
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from './types';
import { importPublicKey, importPrivateKey } from './keyManager';
/**
* Get the Web Crypto API
*/
function getSubtle(): SubtleCrypto {
if (typeof crypto !== 'undefined' && crypto.subtle) {
return crypto.subtle;
}
throw new Error('Web Crypto API not available');
}
/**
* Get crypto.getRandomValues
*/
function getRandomValues(array: Uint8Array): Uint8Array {
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
return crypto.getRandomValues(array);
}
throw new Error('Crypto random not available');
}
/**
* Generate a random AES-256 key
*/
export async function generateAESKey(): Promise<CryptoKey> {
const subtle = getSubtle();
return await subtle.generateKey(
{ name: SYMMETRIC_ALGORITHM, length: SYMMETRIC_KEY_LENGTH },
true,
['encrypt', 'decrypt']
);
}
/**
* Generate a random initialization vector for AES-GCM (12 bytes)
*/
export function generateIV(): Uint8Array {
return getRandomValues(new Uint8Array(IV_LENGTH_BYTES));
}
/**
* Encrypt data with AES-256-GCM
*
* @param plaintext - The data to encrypt (string or ArrayBuffer)
* @param key - The AES key
* @param iv - The initialization vector
* @returns The encrypted ciphertext as ArrayBuffer
*/
export async function encryptAES(
plaintext: string | ArrayBuffer,
key: CryptoKey,
iv: Uint8Array
): Promise<ArrayBuffer> {
const subtle = getSubtle();
const data = typeof plaintext === 'string'
? new TextEncoder().encode(plaintext)
: plaintext;
return await subtle.encrypt(
{ name: SYMMETRIC_ALGORITHM, iv: iv as BufferSource },
key,
data
);
}
/**
* Decrypt data with AES-256-GCM
*
* @param ciphertext - The encrypted data
* @param key - The AES key
* @param iv - The initialization vector
* @returns The decrypted plaintext as ArrayBuffer
*/
export async function decryptAES(
ciphertext: ArrayBuffer,
key: CryptoKey,
iv: Uint8Array
): Promise<ArrayBuffer> {
const subtle = getSubtle();
return await subtle.decrypt(
{ name: SYMMETRIC_ALGORITHM, iv: iv as BufferSource },
key,
ciphertext
);
}
/**
* Export an AES key to raw bytes
*/
export async function exportAESKey(key: CryptoKey): Promise<ArrayBuffer> {
const subtle = getSubtle();
return await subtle.exportKey('raw', key);
}
/**
* Import an AES key from raw bytes
*/
export async function importAESKey(rawKey: ArrayBuffer): Promise<CryptoKey> {
const subtle = getSubtle();
return await subtle.importKey(
'raw',
rawKey,
{ name: SYMMETRIC_ALGORITHM },
false,
['decrypt']
);
}
/**
* Wrap (encrypt) an AES key with an RSA public key
*
* @param aesKeyRaw - The raw AES key bytes
* @param publicKeyJwk - The recipient's RSA public key
* @returns The wrapped key as base64 string
*/
export async function wrapAESKey(
aesKeyRaw: ArrayBuffer,
publicKeyJwk: RSAPublicKeyJWK
): Promise<string> {
const subtle = getSubtle();
const rsaKey = await importPublicKey(publicKeyJwk);
const wrappedKey = await subtle.encrypt(
{ name: KEY_WRAP_ALGORITHM },
rsaKey,
aesKeyRaw
);
return arrayBufferToBase64(wrappedKey);
}
/**
* Unwrap (decrypt) an AES key with an RSA private key
*
* @param wrappedKeyBase64 - The wrapped key as base64 string
* @param privateKeyJwk - The user's RSA private key
* @returns The unwrapped AES key as ArrayBuffer
*/
export async function unwrapAESKey(
wrappedKeyBase64: string,
privateKeyJwk: RSAPrivateKeyJWK
): Promise<ArrayBuffer> {
const subtle = getSubtle();
const rsaKey = await importPrivateKey(privateKeyJwk);
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
return await subtle.decrypt(
{ name: KEY_WRAP_ALGORITHM },
rsaKey,
wrappedKey
);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* E2EE Encryption Library
*
* Public API exports for the Sauci E2EE implementation.
*/
// Types
export type {
KeysMetadata,
EncryptedMessagePayload,
EncryptedMediaPayload,
DecryptedContent,
DecryptedMessageState,
RSAPublicKeyJWK,
RSAPrivateKeyJWK,
EncryptionKeysState,
MasterKey,
EncryptionContext,
} from './types';
// Constants
export {
MESSAGE_VERSION_PLAINTEXT,
MESSAGE_VERSION_E2EE,
} from './constants';
// Key Management
export {
generateAndStoreKeyPair,
getPrivateKey,
getLocalPublicKey,
hasKeyPair,
isPublicKeyUploaded,
markPublicKeyUploaded,
clearKeys,
isCryptoAvailable,
isValidPublicKeyJwk,
sanitizePublicKeyJwk,
} from './keyManager';
// Text Message Encryption/Decryption
export { encryptTextMessage } from './messageEncryption';
export { decryptTextMessage } from './messageDecryption';
// Media Encryption/Decryption
export { encryptMediaFile, getEncryptedFileExtension } from './mediaEncryption';
export { decryptMediaFile, cleanupDecryptedMedia } from './mediaDecryption';
// Admin Keys
export {
getActiveMasterKey,
getAdminPublicKey,
getAdminKeyId,
clearAdminKeyCache,
} from './adminKeys';
// Utilities
export {
arrayBufferToBase64,
base64ToArrayBuffer,
} from './utils';
// Key Rotation
export { triggerKeyRotation } from './triggerKeyRotation';
// Stale Key Repair
export { repairStaleKey } from './repairStaleKey';
@@ -0,0 +1,294 @@
/**
* E2EE Key Manager
*
* Handles RSA key pair generation and secure storage using expo-secure-store.
* Keys are stored in chunks due to SecureStore's 2KB limit.
*/
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
import {
PRIVATE_KEY_PREFIX,
PUBLIC_KEY_UPLOADED,
LOCAL_PUBLIC_KEY,
SECURE_STORE_CHUNK_SIZE,
RSA_KEY_SIZE,
KEY_WRAP_ALGORITHM,
KEY_WRAP_HASH
} from './constants';
import { chunkString } from './utils';
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from './types';
/**
* Check if we're running in a web environment
*/
function isWeb(): boolean {
return Platform.OS === 'web';
}
/**
* Check if crypto is available (polyfill installed and native modules built)
*/
export function isCryptoAvailable(): boolean {
if (isWeb()) return false;
return typeof crypto !== 'undefined' && crypto.subtle !== undefined;
}
/**
* Sanitize a public key by removing trailing dots that may appear
* due to react-native-quick-crypto encoding issues.
*/
export function sanitizePublicKeyJwk(publicKeyJwk: RSAPublicKeyJWK): RSAPublicKeyJWK {
return {
...publicKeyJwk,
n: publicKeyJwk.n.replace(/\.+$/, ''),
e: publicKeyJwk.e.replace(/\.+$/, ''),
};
}
/**
* Validate that a public key JWK looks well-formed for RSA import.
*/
export function isValidPublicKeyJwk(publicKeyJwk: RSAPublicKeyJWK | null): boolean {
if (!publicKeyJwk) return false;
if (publicKeyJwk.kty !== 'RSA') return false;
if (typeof publicKeyJwk.n !== 'string' || typeof publicKeyJwk.e !== 'string') return false;
// Sanitize before validation (handle trailing dots from quick-crypto)
const sanitized = sanitizePublicKeyJwk(publicKeyJwk);
const base64UrlPattern = /^[A-Za-z0-9_\-+/=]+$/;
return base64UrlPattern.test(sanitized.n) && base64UrlPattern.test(sanitized.e);
}
/**
* Get the Web Crypto API
* In React Native with react-native-quick-crypto, it's available globally
*/
function getSubtle(): SubtleCrypto {
// react-native-quick-crypto polyfills global.crypto
if (typeof crypto !== 'undefined' && crypto.subtle) {
return crypto.subtle;
}
throw new Error('Web Crypto API not available. Run: npx expo prebuild --clean');
}
/**
* Generate a new RSA-2048 key pair for E2EE
*
* @returns The public key in JWK format (private key is stored in SecureStore)
*/
export async function generateAndStoreKeyPair(): Promise<RSAPublicKeyJWK> {
if (isWeb()) {
console.warn('E2EE key generation not supported on web platform');
throw new Error('E2EE is not supported on web');
}
const subtle = getSubtle();
// Generate RSA-2048 key pair
const keyPair = await subtle.generateKey(
{
name: KEY_WRAP_ALGORITHM,
modulusLength: RSA_KEY_SIZE,
publicExponent: new Uint8Array([1, 0, 1]), // 65537
hash: KEY_WRAP_HASH,
},
true, // extractable
['encrypt', 'decrypt']
);
// Export keys as JWK
const publicKeyJwk = await subtle.exportKey('jwk', keyPair.publicKey) as RSAPublicKeyJWK;
const privateKeyJwk = await subtle.exportKey('jwk', keyPair.privateKey) as RSAPrivateKeyJWK;
// Store private key in SecureStore (chunked)
await storePrivateKey(privateKeyJwk);
// Store public key locally for quick access
await storeLocalPublicKey(publicKeyJwk);
return publicKeyJwk;
}
/**
* Store private key in SecureStore using chunked storage
* RSA-2048 JWK is ~1.7KB, SecureStore limit is 2KB
*/
async function storePrivateKey(privateKeyJwk: RSAPrivateKeyJWK): Promise<void> {
const json = JSON.stringify(privateKeyJwk);
const chunks = chunkString(json, SECURE_STORE_CHUNK_SIZE);
// Store chunk count
await SecureStore.setItemAsync(
`${PRIVATE_KEY_PREFIX}count`,
chunks.length.toString()
);
// Store each chunk with high security settings
for (let i = 0; i < chunks.length; i++) {
await SecureStore.setItemAsync(
`${PRIVATE_KEY_PREFIX}${i}`,
chunks[i],
{
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
);
}
}
/**
* Store public key locally for quick access during encryption
*/
async function storeLocalPublicKey(publicKeyJwk: RSAPublicKeyJWK): Promise<void> {
await SecureStore.setItemAsync(
LOCAL_PUBLIC_KEY,
JSON.stringify(publicKeyJwk)
);
}
/**
* Retrieve the private key from SecureStore
*
* @returns The private key in JWK format, or null if not found
*/
export async function getPrivateKey(): Promise<RSAPrivateKeyJWK | null> {
if (isWeb()) {
return null;
}
try {
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('')) as RSAPrivateKeyJWK;
} catch (error) {
console.error('Failed to retrieve private key:', error);
return null;
}
}
/**
* Retrieve the locally stored public key
*
* @returns The public key in JWK format, or null if not found
*/
export async function getLocalPublicKey(): Promise<RSAPublicKeyJWK | null> {
if (isWeb()) {
return null;
}
try {
const json = await SecureStore.getItemAsync(LOCAL_PUBLIC_KEY);
if (!json) return null;
return JSON.parse(json) as RSAPublicKeyJWK;
} catch (error) {
console.error('Failed to retrieve local public key:', error);
return null;
}
}
/**
* Check if a key pair exists in SecureStore
*/
export async function hasKeyPair(): Promise<boolean> {
if (isWeb()) {
return false;
}
try {
const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`);
return countStr !== null;
} catch (error) {
console.error('Failed to check for key pair:', error);
return false;
}
}
/**
* Check if public key has been uploaded to Supabase
*/
export async function isPublicKeyUploaded(): Promise<boolean> {
if (isWeb()) {
return false;
}
try {
const uploaded = await SecureStore.getItemAsync(PUBLIC_KEY_UPLOADED);
return uploaded === 'true';
} catch (error) {
console.error('Failed to check public key upload status:', error);
return false;
}
}
/**
* Mark public key as uploaded to Supabase
*/
export async function markPublicKeyUploaded(): Promise<void> {
await SecureStore.setItemAsync(PUBLIC_KEY_UPLOADED, 'true');
}
/**
* Clear all encryption keys from SecureStore
* Used when user logs out or leaves a couple
*/
export async function clearKeys(): Promise<void> {
if (isWeb()) {
return;
}
try {
const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`);
if (countStr) {
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);
await SecureStore.deleteItemAsync(LOCAL_PUBLIC_KEY);
} catch (error) {
console.error('Failed to clear encryption keys:', error);
}
}
/**
* Import an RSA public key from JWK format for encryption operations
*/
export async function importPublicKey(publicKeyJwk: RSAPublicKeyJWK): Promise<CryptoKey> {
const subtle = getSubtle();
return await subtle.importKey(
'jwk',
publicKeyJwk,
{ name: KEY_WRAP_ALGORITHM, hash: KEY_WRAP_HASH },
false,
['encrypt']
);
}
/**
* Import an RSA private key from JWK format for decryption operations
*/
export async function importPrivateKey(privateKeyJwk: RSAPrivateKeyJWK): Promise<CryptoKey> {
const subtle = getSubtle();
return await subtle.importKey(
'jwk',
privateKeyJwk,
{ name: KEY_WRAP_ALGORITHM, hash: KEY_WRAP_HASH },
false,
['decrypt']
);
}
@@ -0,0 +1,95 @@
/**
* Media Decryption
*
* Decrypts E2EE images and videos using a download-first approach.
*/
import * as FileSystem from 'expo-file-system';
import { unwrapAESKey, importAESKey, decryptAES } from './crypto';
import { base64ToArrayBuffer, base64ToUint8Array, arrayBufferToBase64 } from './utils';
import type { KeysMetadata, RSAPrivateKeyJWK } from './types';
/**
* Decrypt an encrypted media file
*
* Downloads the encrypted file, decrypts it, and returns a local file URI.
*
* @param encryptedUrl - Signed URL to the .enc file in storage
* @param encryptionIv - Base64-encoded initialization vector
* @param keysMetadata - The wrapped keys metadata
* @param privateKeyJwk - User's RSA private key
* @param isRecipient - Whether the current user is the recipient
* @param mediaType - Type of media ('image' or 'video')
* @returns Local file URI to the decrypted media
*/
export async function decryptMediaFile(
encryptedUrl: string,
encryptionIv: string,
keysMetadata: KeysMetadata,
privateKeyJwk: RSAPrivateKeyJWK,
isRecipient: boolean,
mediaType: 'image' | 'video'
): Promise<string> {
// 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 for media decryption');
}
const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk);
const aesKey = await importAESKey(rawAesKey);
// 4. Decrypt file
const iv = base64ToUint8Array(encryptionIv);
const decryptedData = await decryptAES(encryptedData, aesKey, iv);
// 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;
}
/**
* Clean up a decrypted media file from cache
*
* @param uri - Local file URI to delete
*/
export async function cleanupDecryptedMedia(uri: string): Promise<void> {
try {
await FileSystem.deleteAsync(uri, { idempotent: true });
} catch (error) {
console.warn('Failed to clean up decrypted media:', error);
}
}
@@ -0,0 +1,82 @@
/**
* Media Encryption
*
* Encrypts images and videos using AES-256-GCM with triple-wrapped keys.
* Uses a download-then-decrypt approach for simplicity.
*/
import * as FileSystem from 'expo-file-system';
import { generateAESKey, generateIV, encryptAES, exportAESKey, wrapAESKey } from './crypto';
import { arrayBufferToBase64, base64ToArrayBuffer } from './utils';
import type { KeysMetadata, EncryptedMediaPayload, RSAPublicKeyJWK } from './types';
/**
* Encrypt a media file (image or video) for E2EE
*
* @param sourceUri - Local file URI of the media to encrypt
* @param senderPublicKeyJwk - Sender's RSA public key
* @param recipientPublicKeyJwk - Recipient's RSA public key (may be null)
* @param adminPublicKeyJwk - Admin's RSA public key
* @param adminKeyId - UUID of the admin key record
* @returns Encrypted media payload with local file URI
*/
export async function encryptMediaFile(
sourceUri: string,
senderPublicKeyJwk: RSAPublicKeyJWK,
recipientPublicKeyJwk: RSAPublicKeyJWK | null,
adminPublicKeyJwk: RSAPublicKeyJWK,
adminKeyId: string
): Promise<EncryptedMediaPayload> {
// 1. Read entire file into memory as base64
const fileBase64 = await FileSystem.readAsStringAsync(sourceUri, {
encoding: FileSystem.EncodingType.Base64,
});
const fileBuffer = base64ToArrayBuffer(fileBase64);
// 2. Generate AES key and IV
const aesKey = await generateAESKey();
const iv = generateIV();
// 3. Encrypt entire file
const encryptedData = await encryptAES(fileBuffer, aesKey, iv);
// 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 exportAESKey(aesKey);
const senderWrappedKey = await wrapAESKey(rawAesKey, senderPublicKeyJwk);
const adminWrappedKey = await wrapAESKey(rawAesKey, adminPublicKeyJwk);
let recipientWrappedKey: string | undefined;
if (recipientPublicKeyJwk) {
recipientWrappedKey = await wrapAESKey(rawAesKey, recipientPublicKeyJwk);
}
return {
encryptedFileUri: encryptedUri,
encryption_iv: arrayBufferToBase64(iv.buffer as ArrayBuffer),
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,
},
};
}
/**
* Get the file extension for encrypted media
*/
export function getEncryptedFileExtension(): string {
return '.enc';
}
@@ -0,0 +1,52 @@
/**
* Text Message Decryption
*
* Decrypts E2EE text messages using the user's RSA private key.
*/
import { unwrapAESKey, importAESKey, decryptAES } from './crypto';
import { base64ToArrayBuffer, base64ToUint8Array } from './utils';
import type { KeysMetadata, RSAPrivateKeyJWK } from './types';
/**
* Decrypt an E2EE text message
*
* @param encryptedContent - Base64-encoded ciphertext
* @param encryptionIv - Base64-encoded initialization vector
* @param keysMetadata - The wrapped keys metadata
* @param privateKeyJwk - User's RSA private key
* @param isRecipient - Whether the current user is the recipient (true) or sender (false)
* @returns The decrypted plaintext message
*/
export async function decryptTextMessage(
encryptedContent: string,
encryptionIv: string,
keysMetadata: KeysMetadata,
privateKeyJwk: RSAPrivateKeyJWK,
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. Unwrap AES key using RSA private key
const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk);
// 3. Import AES key
const aesKey = await importAESKey(rawAesKey);
// 4. Decrypt content
const iv = base64ToUint8Array(encryptionIv);
const ciphertext = base64ToArrayBuffer(encryptedContent);
const plaintext = await decryptAES(ciphertext, aesKey, iv);
// 5. Decode to string
return new TextDecoder().decode(plaintext);
}
@@ -0,0 +1,67 @@
/**
* Text Message Encryption
*
* Encrypts text messages using AES-256-GCM with triple-wrapped keys.
*/
import { generateAESKey, generateIV, encryptAES, exportAESKey, wrapAESKey } from './crypto';
import { arrayBufferToBase64 } from './utils';
import type { KeysMetadata, EncryptedMessagePayload, RSAPublicKeyJWK } from './types';
/**
* Encrypt a text message for E2EE
*
* The message is encrypted with a random AES-256 key, and that key is
* wrapped (encrypted) separately for the sender, recipient, and admin.
*
* @param plaintext - The message text to encrypt
* @param senderPublicKeyJwk - Sender's RSA public key (for their own decryption)
* @param recipientPublicKeyJwk - Recipient's RSA public key (may be null if not yet generated)
* @param adminPublicKeyJwk - Admin's RSA public key (for moderation access)
* @param adminKeyId - UUID of the admin key record in master_keys table
* @returns The encrypted message payload
*/
export async function encryptTextMessage(
plaintext: string,
senderPublicKeyJwk: RSAPublicKeyJWK,
recipientPublicKeyJwk: RSAPublicKeyJWK | null,
adminPublicKeyJwk: RSAPublicKeyJWK,
adminKeyId: string
): Promise<EncryptedMessagePayload> {
// 1. Generate random AES-256 key
const aesKey = await generateAESKey();
// 2. Generate random 96-bit IV
const iv = generateIV();
// 3. Encrypt plaintext
const ciphertext = await encryptAES(plaintext, aesKey, iv);
// 4. Export AES key for wrapping
const rawAesKey = await exportAESKey(aesKey);
// 5. Wrap AES key for each party
const senderWrappedKey = await wrapAESKey(rawAesKey, senderPublicKeyJwk);
const adminWrappedKey = await wrapAESKey(rawAesKey, adminPublicKeyJwk);
let recipientWrappedKey: string | undefined;
if (recipientPublicKeyJwk) {
recipientWrappedKey = await wrapAESKey(rawAesKey, recipientPublicKeyJwk);
}
return {
version: 2,
encrypted_content: arrayBufferToBase64(ciphertext),
encryption_iv: arrayBufferToBase64(iv.buffer as ArrayBuffer),
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,
},
};
}
@@ -0,0 +1,77 @@
/**
* Pending Message Re-encryption (Server Assisted)
*
* Triggers the Supabase Edge Function `reencrypt-pending-messages` to re-wrap
* AES keys for messages that were sent before the current user had E2EE keys.
*
* This does NOT decrypt message content on the server. It only re-wraps the
* per-message AES key using the user's public key (admin recovery model).
*/
import { supabase } from '../supabase';
export interface ReencryptPendingMessagesResult {
success: boolean;
updated: number;
reason?: string;
error?: string;
}
let inFlight: Promise<ReencryptPendingMessagesResult | null> | null = null;
let lastRunAtMs = 0;
const MIN_INTERVAL_MS = 15_000;
export async function reencryptPendingMessages(
options: { force?: boolean } = {}
): Promise<ReencryptPendingMessagesResult | null> {
const now = Date.now();
if (inFlight) return inFlight;
if (!options.force && now - lastRunAtMs < MIN_INTERVAL_MS) {
return null;
}
inFlight = (async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
return null;
}
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
if (!baseUrl) {
return { success: false, updated: 0, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
}
const response = await fetch(`${baseUrl}/functions/v1/reencrypt-pending-messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
const text = await response.text();
return { success: false, updated: 0, error: text || 'Failed to re-encrypt pending messages' };
}
const result = (await response.json()) as ReencryptPendingMessagesResult;
lastRunAtMs = Date.now();
return result;
} catch (err) {
return {
success: false,
updated: 0,
error: err instanceof Error ? err.message : 'Unknown error',
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
@@ -0,0 +1,82 @@
/**
* Stale Key Repair (Server Assisted)
*
* When a message was encrypted offline with a recipient's old public key,
* and the recipient has since regenerated their keys, this function requests
* the server to re-wrap the AES key using the admin recovery model.
*/
import { supabase } from '../supabase';
export interface RepairStaleKeyResult {
success: boolean;
message_id?: string;
error?: string;
}
// Track in-flight repairs to avoid duplicate requests
const inFlightRepairs = new Map<string, Promise<RepairStaleKeyResult>>();
/**
* Request the server to repair a message encrypted with a stale key.
*
* @param messageId - The ID of the message to repair
* @returns Result of the repair operation
*/
export async function repairStaleKey(
messageId: string
): Promise<RepairStaleKeyResult> {
// Check if already in flight
const existing = inFlightRepairs.get(messageId);
if (existing) {
return existing;
}
const promise = (async (): Promise<RepairStaleKeyResult> => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
return { success: false, error: 'Not authenticated' };
}
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
if (!baseUrl) {
return { success: false, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
}
const response = await fetch(`${baseUrl}/functions/v1/repair-stale-key`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ message_id: messageId }),
});
if (!response.ok) {
const text = await response.text();
let errorMessage = 'Failed to repair message key';
try {
const json = JSON.parse(text);
errorMessage = json.error || errorMessage;
} catch {
// Use default error message
}
return { success: false, error: errorMessage };
}
const result = (await response.json()) as RepairStaleKeyResult;
return result;
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Unknown error',
};
} finally {
inFlightRepairs.delete(messageId);
}
})();
inFlightRepairs.set(messageId, promise);
return promise;
}
@@ -0,0 +1,72 @@
/**
* Trigger Key Rotation (Server Assisted)
*
* Calls the Supabase Edge Function `handle-key-rotation` to re-wrap
* all message AES keys with the user's current public key.
*
* This is needed when a user regenerates their keys (new device, app reinstall).
* The admin key is used to unwrap the message keys and re-wrap them for
* the user's new public key.
*/
import { supabase } from '../supabase';
export interface KeyRotationResult {
success: boolean;
updated: number;
reason?: string;
error?: string;
}
let inFlight: Promise<KeyRotationResult | null> | null = null;
export async function triggerKeyRotation(): Promise<KeyRotationResult | null> {
// Prevent concurrent calls
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
return null;
}
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
if (!baseUrl) {
return { success: false, updated: 0, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
}
console.log('[E2EE] Triggering key rotation for regenerated keys...');
const response = await fetch(`${baseUrl}/functions/v1/handle-key-rotation`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
const text = await response.text();
console.error('[E2EE] Key rotation failed:', text);
return { success: false, updated: 0, error: text || 'Failed to rotate keys' };
}
const result = (await response.json()) as KeyRotationResult;
console.log(`[E2EE] Key rotation complete: ${result.updated} messages updated`);
return result;
} catch (err) {
console.error('[E2EE] Key rotation error:', err);
return {
success: false,
updated: 0,
error: err instanceof Error ? err.message : 'Unknown error',
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
+133
View File
@@ -0,0 +1,133 @@
/**
* E2EE Types for Sauci Chat
*
* This file contains all TypeScript interfaces for the E2EE implementation.
*/
/**
* Metadata about how message keys are wrapped for each party
*/
export 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 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;
/** Symmetric encryption algorithm */
algorithm: 'AES-256-GCM';
/** Key wrapping algorithm */
key_wrap_algorithm: 'RSA-OAEP-SHA256';
/** True if recipient key needs to be added when they generate their keys */
pending_recipient?: boolean;
}
/**
* Payload for an encrypted text message
*/
export interface EncryptedMessagePayload {
version: 2;
encrypted_content: string;
encryption_iv: string;
keys_metadata: KeysMetadata;
}
/**
* Payload for encrypted media
*/
export interface EncryptedMediaPayload {
encryptedFileUri: string;
encryption_iv: string;
keys_metadata: KeysMetadata;
}
/**
* Decrypted content result
*/
export interface DecryptedContent {
content: string | null;
mediaUrl: string | null;
}
/**
* State for useDecryptedMessage hook
*/
export type DecryptedMessageErrorCode =
| 'E2EE_NOT_SUPPORTED'
| 'E2EE_KEYS_UNAVAILABLE'
| 'E2EE_MISSING_FIELDS'
| 'E2EE_PENDING_RECIPIENT_KEY'
| 'E2EE_REPAIRING_STALE_KEY'
| 'E2EE_DECRYPT_FAILED';
export interface DecryptedMessageState {
content: string | null;
mediaUri: string | null;
isDecrypting: boolean;
error: Error | null;
errorCode?: DecryptedMessageErrorCode;
}
/**
* RSA public key in JWK format (JSON Web Key)
*/
export interface RSAPublicKeyJWK {
kty: 'RSA';
n: string; // modulus
e: string; // exponent
alg?: string;
key_ops?: string[];
}
/**
* RSA private key in JWK format (includes public key components)
*/
export interface RSAPrivateKeyJWK extends RSAPublicKeyJWK {
d: string; // private exponent
p: string; // first prime factor
q: string; // second prime factor
dp: string; // first factor CRT exponent
dq: string; // second factor CRT exponent
qi: string; // first CRT coefficient
}
/**
* State for useEncryptionKeys hook
*/
export interface EncryptionKeysState {
privateKeyJwk: RSAPrivateKeyJWK | null;
publicKeyJwk: RSAPublicKeyJWK | null;
isLoading: boolean;
hasKeys: boolean;
error: Error | null;
}
/**
* Master key record from database
*/
export interface MasterKey {
id: string;
key_name: string;
public_key_jwk: RSAPublicKeyJWK;
is_active: boolean;
created_at: string;
rotated_at: string | null;
}
/**
* Encryption context for sending messages
*/
export interface EncryptionContext {
senderPublicKeyJwk: RSAPublicKeyJWK;
recipientPublicKeyJwk: RSAPublicKeyJWK | null;
adminPublicKeyJwk: RSAPublicKeyJWK;
adminKeyId: string;
}
+72
View File
@@ -0,0 +1,72 @@
/**
* E2EE Utility Functions
*
* Base64 and ArrayBuffer conversion utilities for encryption operations.
*/
/**
* Convert ArrayBuffer to base64 string
*/
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);
}
/**
* Convert base64 string to ArrayBuffer
*/
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;
}
/**
* Convert Uint8Array to base64 string
*/
export function uint8ArrayToBase64(array: Uint8Array): string {
let binary = '';
for (let i = 0; i < array.length; i++) {
binary += String.fromCharCode(array[i]);
}
return btoa(binary);
}
/**
* Convert base64 string to Uint8Array
*/
export function base64ToUint8Array(base64: string): Uint8Array {
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;
}
/**
* Split string into chunks of specified size
*/
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;
}
/**
* Generate a random string for unique identifiers
*/
export function generateRandomId(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}