| |
|
| |
|
| | import { ref, set, get, Database } from 'firebase/database'; |
| | import { getDoc, doc, Firestore } from 'firebase/firestore'; |
| | import { JsonWebKey } from 'crypto'; |
| | import type { ChatRecipient, Group, User, EncryptedMessage } from '@/lib/types'; |
| |
|
| | const isBrowser = typeof window !== 'undefined'; |
| |
|
| | |
| | const groupKeyCache = new Map<string, Map<number, CryptoKey>>(); |
| |
|
| |
|
| | async function setupUserKeys(userId: string, rtdb: Database) { |
| | if (!isBrowser) return; |
| |
|
| | const existingKeys = localStorage.getItem(`cryptoKeys_${userId}`); |
| | if (existingKeys) { |
| | return; |
| | } |
| |
|
| | try { |
| | const keyPair = await window.crypto.subtle.generateKey( |
| | { |
| | name: 'RSA-OAEP', |
| | modulusLength: 2048, |
| | publicExponent: new Uint8Array([1, 0, 1]), |
| | hash: 'SHA-256', |
| | }, |
| | true, |
| | ['encrypt', 'decrypt'] |
| | ); |
| |
|
| | const publicKey = await window.crypto.subtle.exportKey('jwk', keyPair.publicKey); |
| | const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey); |
| |
|
| | await set(ref(rtdb, `users/${userId}/publicKey`), publicKey); |
| |
|
| | const keys = { public: publicKey, private: privateKey }; |
| | localStorage.setItem(`cryptoKeys_${userId}`, JSON.stringify(keys)); |
| |
|
| | } catch (error) { |
| | console.error("Key generation failed:", error); |
| | } |
| | } |
| |
|
| | async function getPublicKey(recipientId: string, rtdb: Database): Promise<JsonWebKey | null> { |
| | if (!isBrowser) return null; |
| | const recipientKeyRef = ref(rtdb, `users/${recipientId}/publicKey`); |
| | const snapshot = await get(recipientKeyRef); |
| | return snapshot.exists() ? snapshot.val() : null; |
| | } |
| |
|
| | async function encryptMessage(message: string, recipientId: string, rtdb: Database): Promise<EncryptedMessage> { |
| | if (!isBrowser) throw new Error("Encryption can only be done in the browser."); |
| |
|
| | |
| | const symmetricKey = await window.crypto.subtle.generateKey( |
| | { name: 'AES-GCM', length: 256 }, |
| | true, |
| | ['encrypt', 'decrypt'] |
| | ); |
| |
|
| | |
| | const iv = window.crypto.getRandomValues(new Uint8Array(12)); |
| | const encodedMessage = new TextEncoder().encode(message); |
| | const encryptedData = await window.crypto.subtle.encrypt( |
| | { name: 'AES-GCM', iv }, |
| | symmetricKey, |
| | encodedMessage |
| | ); |
| |
|
| | |
| | const symmetricKeyJwk = await window.crypto.subtle.exportKey('jwk', symmetricKey); |
| | const symmetricKeyString = JSON.stringify(symmetricKeyJwk); |
| |
|
| | const recipientPublicKeyJwk = await getPublicKey(recipientId, rtdb); |
| | if (!recipientPublicKeyJwk) throw new Error("Recipient is offline or has no encryption key."); |
| |
|
| | const recipientPublicKey = await window.crypto.subtle.importKey( |
| | 'jwk', |
| | recipientPublicKeyJwk, |
| | { name: 'RSA-OAEP', hash: 'SHA-256' }, |
| | false, |
| | ['encrypt'] |
| | ); |
| |
|
| | const encryptedSymmetricKey = await window.crypto.subtle.encrypt( |
| | { name: 'RSA-OAEP' }, |
| | recipientPublicKey, |
| | new TextEncoder().encode(symmetricKeyString) |
| | ); |
| |
|
| | |
| | const ciphertext = btoa(String.fromCharCode(...new Uint8Array(encryptedData))); |
| | const encryptedKeyString = btoa(String.fromCharCode(...new Uint8Array(encryptedSymmetricKey))); |
| |
|
| | return { |
| | key: encryptedKeyString, |
| | ciphertext, |
| | iv: Array.from(iv), |
| | }; |
| | } |
| |
|
| |
|
| | async function decryptMessage(encryptedMessage: EncryptedMessage, userId: string): Promise<string> { |
| | if (!isBrowser) throw new Error("Decryption can only be done in the browser."); |
| |
|
| | const keysString = localStorage.getItem(`cryptoKeys_${userId}`); |
| | if (!keysString) throw new Error(`Cannot decrypt: No local private key found for user ${userId}.`); |
| | const keys = JSON.parse(keysString); |
| |
|
| | |
| | const privateKey = await window.crypto.subtle.importKey( |
| | 'jwk', |
| | keys.private, |
| | { name: 'RSA-OAEP', hash: 'SHA-256' }, |
| | true, |
| | ['decrypt'] |
| | ); |
| |
|
| | const encryptedKeyData = Uint8Array.from(atob(encryptedMessage.key), c => c.charCodeAt(0)); |
| | const decryptedKeyString = await window.crypto.subtle.decrypt({ name: 'RSA-OAEP' }, privateKey, encryptedKeyData); |
| | const symmetricKeyJwk = JSON.parse(new TextDecoder().decode(decryptedKeyString)); |
| | |
| | const symmetricKey = await window.crypto.subtle.importKey( |
| | 'jwk', |
| | symmetricKeyJwk, |
| | { name: 'AES-GCM' }, |
| | true, |
| | ['decrypt'] |
| | ); |
| | |
| | |
| | const iv = new Uint8Array(encryptedMessage.iv!); |
| | const encryptedData = Uint8Array.from(atob(encryptedMessage.ciphertext), c => c.charCodeAt(0)); |
| | const decrypted = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, symmetricKey, encryptedData); |
| |
|
| | return new TextDecoder().decode(decrypted); |
| | } |
| |
|
| | |
| |
|
| | async function createEncryptedGroupKey(memberUids: string[], rtdb: Database) { |
| | if (!isBrowser) throw new Error("Group key generation must be done in the browser."); |
| |
|
| | const groupKey = await window.crypto.subtle.generateKey( |
| | { name: 'AES-GCM', length: 256 }, |
| | true, |
| | ['encrypt', 'decrypt'] |
| | ); |
| |
|
| | const exportedKey = await window.crypto.subtle.exportKey('jwk', groupKey); |
| | const groupKeyString = JSON.stringify(exportedKey); |
| |
|
| | const encryptedKeys: { [uid: string]: string } = {}; |
| |
|
| | for (const uid of memberUids) { |
| | try { |
| | |
| | const encryptedKeyPayload = await encryptMessage(groupKeyString, uid, rtdb); |
| | encryptedKeys[uid] = JSON.stringify(encryptedKeyPayload); |
| | } catch (error) { |
| | console.warn(`Could not encrypt group key for user ${uid}, they might be offline or without a public key.`); |
| | } |
| | } |
| |
|
| | return { encryptedKeys, groupKeyString }; |
| | } |
| |
|
| | async function getGroupKey(groupId: string, keyVersion: number, userId: string, db: Firestore): Promise<CryptoKey | null> { |
| | if (!isBrowser) return null; |
| |
|
| | |
| | const versionCache = groupKeyCache.get(groupId); |
| | if (versionCache && versionCache.has(keyVersion)) { |
| | return versionCache.get(keyVersion)!; |
| | } |
| |
|
| | |
| | const groupRef = doc(db, 'groups', groupId); |
| | const groupSnap = await getDoc(groupRef); |
| | if (!groupSnap.exists()) { |
| | console.error(`Group ${groupId} not found.`); |
| | return null; |
| | } |
| | const groupData = groupSnap.data() as Group; |
| | |
| | const encryptedKeyPayloadString = groupData.encryptedKeys[userId]; |
| | |
| | if (!encryptedKeyPayloadString) { |
| | console.warn(`No encrypted key found for user ${userId} in group ${groupId}.`); |
| | return null; |
| | } |
| |
|
| | try { |
| | const encryptedKeyPayload: EncryptedMessage = JSON.parse(encryptedKeyPayloadString); |
| | const decryptedKeyString = await decryptMessage(encryptedKeyPayload, userId); |
| | const keyJwk = JSON.parse(decryptedKeyString); |
| | const groupKey = await window.crypto.subtle.importKey( |
| | 'jwk', |
| | keyJwk, |
| | { name: 'AES-GCM' }, |
| | true, |
| | ['encrypt', 'decrypt'] |
| | ); |
| | |
| | |
| | if (!groupKeyCache.has(groupId)) { |
| | groupKeyCache.set(groupId, new Map()); |
| | } |
| | groupKeyCache.get(groupId)!.set(keyVersion, groupKey); |
| |
|
| | return groupKey; |
| | } catch (error) { |
| | console.error(`Failed to decrypt/import group key for group ${groupId}, version ${keyVersion}:`, error); |
| | return null; |
| | } |
| | } |
| |
|
| | async function encryptGroupMessage(message: string, groupKey: CryptoKey): Promise<string> { |
| | if (!isBrowser) throw new Error("Encryption must be done in the browser."); |
| |
|
| | const iv = window.crypto.getRandomValues(new Uint8Array(12)); |
| | const data = new TextEncoder().encode(message); |
| | const encrypted = await window.crypto.subtle.encrypt( |
| | { name: 'AES-GCM', iv }, |
| | groupKey, |
| | data |
| | ); |
| |
|
| | const combined = new Uint8Array(iv.length + encrypted.byteLength); |
| | combined.set(iv); |
| | combined.set(new Uint8Array(encrypted), iv.length); |
| |
|
| | return btoa(String.fromCharCode(...combined)); |
| | } |
| |
|
| |
|
| | async function decryptGroupMessage(encryptedMessage: string, groupKey: CryptoKey): Promise<string> { |
| | if (!isBrowser) throw new Error("Decryption must be done in the browser."); |
| |
|
| | const combined = Uint8Array.from(atob(encryptedMessage), c => c.charCodeAt(0)); |
| | const iv = combined.slice(0, 12); |
| | const data = combined.slice(12); |
| |
|
| | const decrypted = await window.crypto.subtle.decrypt( |
| | { name: 'AES-GCM', iv }, |
| | groupKey, |
| | data |
| | ); |
| |
|
| | return new TextDecoder().decode(decrypted); |
| | } |
| |
|
| | |
| |
|
| | async function encryptFile( |
| | file: File, |
| | recipient: ChatRecipient, |
| | senderUid: string, |
| | rtdb: Database, |
| | db: Firestore, |
| | currentGroup?: Group |
| | ): Promise<{ encryptedBlob: Blob; encryptionKey: { [uid: string]: string } | { group: string } }> { |
| | if (!isBrowser) throw new Error("File encryption must be done in the browser."); |
| |
|
| | const fileKey = await window.crypto.subtle.generateKey( |
| | { name: 'AES-GCM', length: 256 }, |
| | true, |
| | ['encrypt', 'decrypt'] |
| | ); |
| |
|
| | const iv = window.crypto.getRandomValues(new Uint8Array(12)); |
| | const fileBuffer = await file.arrayBuffer(); |
| | const encryptedFileBuffer = await window.crypto.subtle.encrypt( |
| | { name: 'AES-GCM', iv }, |
| | fileKey, |
| | fileBuffer |
| | ); |
| | |
| | const combinedBuffer = new Uint8Array(iv.length + encryptedFileBuffer.byteLength); |
| | combinedBuffer.set(iv); |
| | combinedBuffer.set(new Uint8Array(encryptedFileBuffer), iv.length); |
| | const encryptedBlob = new Blob([combinedBuffer]); |
| |
|
| | const fileKeyJwk = await window.crypto.subtle.exportKey('jwk', fileKey); |
| | const fileKeyString = JSON.stringify(fileKeyJwk); |
| |
|
| | if (recipient.isGroup && currentGroup) { |
| | if (!currentGroup.keyLastRotatedAt) throw new Error("Group key version is missing."); |
| | const groupKey = await getGroupKey(currentGroup.id, currentGroup.keyLastRotatedAt, senderUid, db); |
| | if (!groupKey) throw new Error("Cannot encrypt file: Group key not available."); |
| | |
| | const encryptedFileKey = await encryptGroupMessage(fileKeyString, groupKey); |
| | return { encryptedBlob, encryptionKey: { group: encryptedFileKey } }; |
| |
|
| | } else if (!recipient.isGroup) { |
| | const encryptionKey: { [uid: string]: string } = {}; |
| | const participants = [senderUid, recipient.uid]; |
| | for (const uid of participants) { |
| | |
| | const recipientPublicKeyJwk = await getPublicKey(uid, rtdb); |
| | if (!recipientPublicKeyJwk) throw new Error(`Recipient ${uid} has no public key.`); |
| | const recipientPublicKey = await window.crypto.subtle.importKey('jwk', recipientPublicKeyJwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']); |
| | const encryptedKey = await window.crypto.subtle.encrypt({ name: 'RSA-OAEP' }, recipientPublicKey, new TextEncoder().encode(fileKeyString)); |
| | encryptionKey[uid] = btoa(String.fromCharCode(...new Uint8Array(encryptedKey))); |
| | } |
| | return { encryptedBlob, encryptionKey }; |
| | } |
| | |
| | throw new Error("Invalid recipient type for file encryption."); |
| | } |
| |
|
| | async function decryptFile( |
| | encryptedBlob: Blob, |
| | encryptionKey: { [uid: string]: string } | { group: string }, |
| | keyVersion: number | undefined, |
| | currentUser: User, |
| | db: Firestore, |
| | group?: Group, |
| | originalFileType?: string |
| | ): Promise<Blob> { |
| | if (!isBrowser) throw new Error("File decryption must be done in the browser."); |
| | |
| | try { |
| | let decryptedKeyString: string; |
| |
|
| | |
| | if (group && 'group' in encryptionKey) { |
| | const currentKeyVersion = group.keyLastRotatedAt; |
| | if (!currentKeyVersion) throw new Error("Group is missing a current key version."); |
| |
|
| | const groupKey = await getGroupKey(group.id, currentKeyVersion, currentUser.uid, db); |
| | if (!groupKey) throw new Error("Decryption failed: Key is outdated or user lacks permission."); |
| |
|
| | decryptedKeyString = await decryptGroupMessage(encryptionKey.group, groupKey); |
| | } else if ('group' in encryptionKey) { |
| | throw new Error("Media is group encrypted, but no group context provided."); |
| | } else { |
| | const encryptedKeyForUser = encryptionKey[currentUser.uid]; |
| | if (!encryptedKeyForUser) throw new Error("Missing personal encryption key for media."); |
| | |
| | |
| | const keysString = localStorage.getItem(`cryptoKeys_${currentUser.uid}`); |
| | if (!keysString) throw new Error(`No local private key for user ${currentUser.uid}.`); |
| | const keys = JSON.parse(keysString); |
| | const privateKey = await window.crypto.subtle.importKey('jwk', keys.private, { name: 'RSA-OAEP', hash: 'SHA-256' }, true, ['decrypt']); |
| | const encryptedData = Uint8Array.from(atob(encryptedKeyForUser), c => c.charCodeAt(0)); |
| | const decrypted = await window.crypto.subtle.decrypt({ name: 'RSA-OAEP' }, privateKey, encryptedData); |
| | decryptedKeyString = new TextDecoder().decode(decrypted); |
| | } |
| |
|
| | const fileKeyJwk = JSON.parse(decryptedKeyString); |
| | const fileKey = await window.crypto.subtle.importKey( |
| | 'jwk', |
| | fileKeyJwk, |
| | { name: 'AES-GCM' }, |
| | true, |
| | ['decrypt'] |
| | ); |
| | |
| | const encryptedBuffer = await encryptedBlob.arrayBuffer(); |
| | const iv = encryptedBuffer.slice(0, 12); |
| | const data = encryptedBuffer.slice(12); |
| |
|
| | const decryptedFileBuffer = await window.crypto.subtle.decrypt( |
| | { name: 'AES-GCM', iv: new Uint8Array(iv) }, |
| | fileKey, |
| | data |
| | ); |
| | |
| | |
| | if (originalFileType) { |
| | return new Blob([decryptedFileBuffer], { type: originalFileType }); |
| | } |
| |
|
| | return new Blob([decryptedFileBuffer]); |
| |
|
| | } catch (error: any) { |
| | console.error("File decryption failed:", error); |
| | if (error.message.includes("Decryption failed: Key is outdated")) { |
| | throw error; |
| | } |
| | throw new Error("Could not decrypt media file."); |
| | } |
| | } |
| |
|
| |
|
| | export const cryptoService = { |
| | setupUserKeys, |
| | getPublicKey, |
| | encryptMessage, |
| | decryptMessage, |
| | createEncryptedGroupKey, |
| | getGroupKey, |
| | encryptGroupMessage, |
| | decryptGroupMessage, |
| | encryptFile, |
| | decryptFile, |
| | storeGroupKey: (groupId: string, keyVersion: number, keyString: string) => { |
| | if (!isBrowser) return; |
| | try { |
| | const keyJwk = JSON.parse(keyString); |
| | window.crypto.subtle.importKey('jwk', keyJwk, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']) |
| | .then(key => { |
| | if (!groupKeyCache.has(groupId)) { |
| | groupKeyCache.set(groupId, new Map()); |
| | } |
| | groupKeyCache.get(groupId)!.set(keyVersion, key); |
| | }); |
| | } catch (error) { |
| | console.error("Failed to store group key in cache", error); |
| | } |
| | }, |
| | clearGroupKeyCache: (groupId: string) => { |
| | if (!isBrowser) return; |
| | groupKeyCache.delete(groupId); |
| | } |
| | }; |
| |
|