mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
Implement E2EE and message content moderation
This commit is contained in:
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;
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
Reference in new issue
Block a user