From 1b2f825a74bc0d98fb3165a8c8d6410c9648f05c Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 1 Oct 2025 22:50:32 +0300 Subject: [PATCH] Implement call encryption Security fixes --- frontend/src/core/calls/e2eeWorker.ts | 100 ++++++++ frontend/src/core/calls/encryption.ts | 236 ++++++++++++++++++ frontend/src/core/calls/signaling.ts | 18 +- frontend/src/core/calls/webrtc.ts | 193 +++++++++++++- frontend/src/core/types.d.ts | 9 +- frontend/src/core/websocket.ts | 4 +- frontend/src/pages/chat/css/_callWindow.scss | 29 +++ frontend/src/pages/chat/hooks/useAudioCall.ts | 54 +++- frontend/src/pages/chat/state.ts | 41 ++- 9 files changed, 657 insertions(+), 27 deletions(-) create mode 100644 frontend/src/core/calls/e2eeWorker.ts create mode 100644 frontend/src/core/calls/encryption.ts diff --git a/frontend/src/core/calls/e2eeWorker.ts b/frontend/src/core/calls/e2eeWorker.ts new file mode 100644 index 0000000..3d453de --- /dev/null +++ b/frontend/src/core/calls/e2eeWorker.ts @@ -0,0 +1,100 @@ +/** + * E2EE Worker for WebRTC Insertable Streams + * Conditionally encrypts or decrypts encoded audio frames using AES-GCM + */ + +export interface EncodedFrame { + data: Uint8Array; +} + +export interface WorkerOptions { + key: CryptoKey; + mode: 'encrypt' | 'decrypt'; + sessionId?: string; // For replay protection +} + +addEventListener("rtctransform", (event) => { + const { transformer } = event; + const { readable, writable } = transformer; + const { key, mode, sessionId } = transformer.options as WorkerOptions; + + // Generate a random base IV once per transform session + const ivBase = crypto.getRandomValues(new Uint8Array(8)); // 8 random bytes + let frameCounter = 0; + let lastFrameTime = 0; + const FRAME_WINDOW_MS = 5000; // 5 second window for replay protection + + async function transform(encodedFrame: EncodedFrame, controller: TransformStreamDefaultController) { + try { + const currentTime = Date.now(); + + // Replay protection for decryption + if (mode === 'decrypt') { + // Simple time-based replay protection + if (currentTime - lastFrameTime > FRAME_WINDOW_MS && lastFrameTime > 0) { + console.warn("Potential replay attack detected - frame outside time window"); + controller.error(new Error("Replay attack detected")); + return; + } + lastFrameTime = currentTime; + } + + // Create IV: 8 random bytes + 4-byte frame counter + const iv = new Uint8Array(12); + iv.set(ivBase, 0); // Copy random base + const view = new DataView(iv.buffer); + view.setUint32(8, frameCounter++, false); // Big-endian frame counter + + const data = new Uint8Array(encodedFrame.data); + + // Add frame metadata for authentication + const frameMetadata = new TextEncoder().encode(JSON.stringify({ + frameNumber: frameCounter - 1, + timestamp: currentTime, + sessionId: sessionId || 'default' + })); + + // Combine frame data with metadata + const combinedData = new Uint8Array(data.length + frameMetadata.length); + combinedData.set(frameMetadata, 0); + combinedData.set(data, frameMetadata.length); + + const params: AesGcmParams = { name: 'AES-GCM', iv }; + + let result: ArrayBuffer; + if (mode === 'encrypt') { + result = await crypto.subtle.encrypt(params, key, combinedData); + } else { + result = await crypto.subtle.decrypt(params, key, combinedData); + + // Verify frame metadata on decryption + const decryptedData = new Uint8Array(result); + const metadataLength = frameMetadata.length; + const extractedMetadata = decryptedData.slice(0, metadataLength); + const extractedData = decryptedData.slice(metadataLength); + + try { + const metadata = JSON.parse(new TextDecoder().decode(extractedMetadata)); + if (metadata.frameNumber !== frameCounter - 1) { + throw new Error("Frame sequence number mismatch"); + } + result = extractedData.buffer; + } catch (parseError) { + console.warn("Frame authentication failed:", parseError); + controller.error(new Error("Frame authentication failed")); + return; + } + } + + encodedFrame.data = new Uint8Array(result); + controller.enqueue(encodedFrame); + } catch (e) { + console.error(`E2EE ${mode} failed:`, e); + controller.error(new Error(`E2EE ${mode} failed`)); + } + } + + readable + .pipeThrough(new TransformStream({ transform })) + .pipeTo(writable); +}); diff --git a/frontend/src/core/calls/encryption.ts b/frontend/src/core/calls/encryption.ts new file mode 100644 index 0000000..9ab4ebe --- /dev/null +++ b/frontend/src/core/calls/encryption.ts @@ -0,0 +1,236 @@ +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; +import { randomBytes } from "@/utils/crypto/kdf"; +import { b64, ub64 } from "@/utils/utils"; +import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; +import { getCurrentKeys } from "@/core/api/authApi"; + +export interface CallSessionKey { + key: Uint8Array; + hash: string; // For emoji display +} + +export interface CallKeyExchange { + type: "call_key_exchange"; + sessionKeyHash: string; + encryptedSessionKey: EncryptedCallMessage; +} + +export interface EncryptedCallMessage { + iv: string; + ciphertext: string; + salt: string; + iv2: string; + wrappedSessionKey: string; +} + +/** + * Generate a call session key (for initiator only) + */ +export async function generateCallSessionKey(): Promise { + // Generate session key material + const sessionKeyMaterial = randomBytes(32); + + // Generate hash for emoji display (first 4 bytes of SHA-256 hash) + const hashBuffer = await crypto.subtle.digest("SHA-256", sessionKeyMaterial.buffer as ArrayBuffer); + const hash = b64(new Uint8Array(hashBuffer.slice(0, 4))); + + return { + key: sessionKeyMaterial, + hash + }; +} + +/** + * Rotate a session key by generating a completely new key + * This provides forward secrecy for long-running calls + */ +export async function rotateCallSessionKey(_currentSessionKey: CallSessionKey): Promise { + // Generate new session key material (completely independent of current key) + const newSessionKeyMaterial = randomBytes(32); + + // Generate new hash for emoji display + const hashBuffer = await crypto.subtle.digest("SHA-256", newSessionKeyMaterial.buffer as ArrayBuffer); + const newHash = b64(new Uint8Array(hashBuffer.slice(0, 4))); + + return { + key: newSessionKeyMaterial, + hash: newHash + }; +} + +/** + * Create session key from hash (for backward compatibility) + * @deprecated Use deriveCallSessionKeyFromSharedSecret instead + */ +export async function createCallSessionKeyFromHash(hash: string): Promise { + // For backward compatibility, generate a deterministic key from the hash + const hashBytes = ub64(hash); + const sessionKey = new Uint8Array(32); + + // Repeat the hash bytes to fill 32 bytes + for (let i = 0; i < 32; i++) { + sessionKey[i] = hashBytes[i % hashBytes.length]; + } + + return { + key: sessionKey, + hash + }; +} + +/** + * Derive session key from ECDH shared secret and session key hash + * This creates a deterministic but cryptographically secure key + */ +export async function deriveCallSessionKeyFromSharedSecret( + sharedSecret: Uint8Array, + sessionKeyHash: string, + isInitiator: boolean +): Promise { + // Use HKDF to derive the session key from the shared secret + // Include the session key hash and role to ensure uniqueness + const info = new TextEncoder().encode(`call-session-${sessionKeyHash}-${isInitiator ? 'initiator' : 'receiver'}`); + const salt = new Uint8Array(32); // Zero salt for deterministic derivation + + // Import the shared secret as a raw key for HKDF + const sharedKey = await crypto.subtle.importKey( + 'raw', + sharedSecret.buffer as ArrayBuffer, + { name: 'HKDF' }, + false, + ['deriveKey'] + ); + + // Derive the session key using HKDF + const sessionKey = await crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: salt, + info: info + }, + sharedKey, + { name: 'AES-GCM', length: 256 }, + true, // Make the key extractable so we can export it + ['encrypt', 'decrypt'] + ); + + // Export the raw key material + const sessionKeyMaterial = await crypto.subtle.exportKey('raw', sessionKey); + + return { + key: new Uint8Array(sessionKeyMaterial), + hash: sessionKeyHash + }; +} + +/** + * Encrypt a call signaling message with the session key + */ +export async function encryptCallMessage(message: any, sessionKey: Uint8Array): Promise { + const messageKey = await importAesGcmKey(sessionKey); + const encrypted = await aesGcmEncrypt(messageKey, new TextEncoder().encode(JSON.stringify(message))); + + return { + iv: b64(encrypted.iv), + ciphertext: b64(encrypted.ciphertext), + salt: "", // Not used for message encryption, only for key wrapping + iv2: "", + wrappedSessionKey: "" + }; +} + +/** + * Decrypt a call signaling message + */ +export async function decryptCallMessage(encryptedMessage: EncryptedCallMessage, sessionKey: Uint8Array): Promise { + const messageKey = await importAesGcmKey(sessionKey); + const decrypted = await aesGcmDecrypt(messageKey, ub64(encryptedMessage.iv), ub64(encryptedMessage.ciphertext)); + return JSON.parse(new TextDecoder().decode(decrypted)); +} + +/** + * Generate 4 emojis representing the call session key + */ +export function generateCallEmojis(sessionKeyHash: string): string[] { + // Convert hash to numbers and map to emoji ranges + const hashBytes = new Uint8Array(ub64(sessionKeyHash)); + const emojis: string[] = []; + + // Different emoji categories for variety + const emojiSets = [ + ["đŸŽĩ", "đŸŽļ", "🎤", "🎧", "đŸŽŧ", "🎹", "đŸĨ", "đŸŽē", "🎸", "đŸŽģ"], // Music + ["đŸ”Ĩ", "đŸ’Ģ", "⭐", "✨", "🌟", "đŸ’Ĩ", "⚡", "🌈", "🎆", "🎇"], // Energy + ["🚀", "🛸", "đŸ›°ī¸", "🌌", "🔭", "âš™ī¸", "🔧", "⚡", "💡", "đŸ”Ŧ"], // Tech/Space + ["🎭", "đŸŽĒ", "🎨", "đŸŽŦ", "📷", "đŸŽĨ", "đŸ“ē", "🎮", "đŸ•šī¸", "đŸŽ¯"] // Entertainment + ]; + + for (let i = 0; i < 4; i++) { + const set = emojiSets[i % emojiSets.length]; + const index = hashBytes[i % hashBytes.length] % set.length; + emojis.push(set[index]); + } + + return emojis; +} + +// HKDF info for CALL key wrapping (distinct from DM's info) +const CALL_INFO = new Uint8Array([2]); + +export interface WrappedSessionKeyPayload { + salt: string; // b64 salt used in HKDF + iv2: string; // b64 IV used to wrap session key + wrapped: string; // b64 ciphertext of wrapped session key +} + +/** + * Wrap a session key for a recipient using ECDH (X25519) and AES-GCM + */ +export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + const salt = randomBytes(16); + const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO); + const wk = await importAesGcmKey(wkRaw); + const wrap = await aesGcmEncrypt(wk, sessionKey); + return { + salt: b64(salt), + iv2: b64(wrap.iv), + wrapped: b64(wrap.ciphertext) + }; +} + +/** + * Create a shared secret and derive session key for the receiver + */ +export async function createSharedSecretAndDeriveSessionKey( + senderPublicKeyB64: string, + sessionKeyHash: string, + isInitiator: boolean +): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // Create shared secret using ECDH + const sharedSecret = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); + + // Derive the session key from the shared secret + return await deriveCallSessionKeyFromSharedSecret(sharedSecret, sessionKeyHash, isInitiator); +} + +/** + * Unwrap a received session key using sender's public key + */ +export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + const salt = ub64(payload.salt); + const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO); + const wk = await importAesGcmKey(wkRaw); + const sessionKey = await aesGcmDecrypt(wk, ub64(payload.iv2), ub64(payload.wrapped)); + return new Uint8Array(sessionKey); +} diff --git a/frontend/src/core/calls/signaling.ts b/frontend/src/core/calls/signaling.ts index 8280246..831c6f1 100644 --- a/frontend/src/core/calls/signaling.ts +++ b/frontend/src/core/calls/signaling.ts @@ -1,9 +1,10 @@ -import type { WebSocketMessage, CallSignalingData, CallInvite } from "@/core/types"; +import type { CallInvite, CallSignalingMessage } from "@/core/types"; import * as WebRTC from "./webrtc"; export interface CallState { receiveCall: (userId: number, username: string) => void; endCall: () => void; + setCallSessionKeyHash: (sessionKeyHash: string) => void; } export class CallSignalingHandler { @@ -13,7 +14,7 @@ export class CallSignalingHandler { this.getState = getState; } - handleWebSocketMessage(message: WebSocketMessage) { + handleWebSocketMessage(message: CallSignalingMessage) { if (message.type !== "call_signaling") { return; } @@ -48,6 +49,9 @@ export class CallSignalingHandler { case "call_end": this.handleCallEnd(data); break; + case "call_session_key": + this.handleCallSessionKey(data); + break; } } @@ -112,4 +116,14 @@ export class CallSignalingHandler { // End the call state.endCall(); } + + private handleCallSessionKey({ sessionKeyHash, data, ...message }: CallSignalingMessage) { + const state = this.getState(); + if (sessionKeyHash) { + state.setCallSessionKeyHash(sessionKeyHash); + } + if (data?.wrappedSessionKey && message.fromUserId) { + WebRTC.receiveWrappedSessionKey(message.fromUserId, data.wrappedSessionKey, sessionKeyHash); + } + } } diff --git a/frontend/src/core/calls/webrtc.ts b/frontend/src/core/calls/webrtc.ts index 77d0397..1424230 100644 --- a/frontend/src/core/calls/webrtc.ts +++ b/frontend/src/core/calls/webrtc.ts @@ -1,13 +1,11 @@ import { getAuthHeaders } from "@/core/api/authApi"; -import type { IceServersResponse } from "@/core/types"; +import type { CallSignalingMessage, IceServersResponse } from "@/core/types"; import { request } from "@/core/websocket"; - -export interface CallSignalingMessage { - type: "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject"; - fromUserId: number; - toUserId: number; - data?: any; -} +import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender } from "./encryption"; +import { fetchUserPublicKey } from "@/core/api/dmApi"; +import { importAesGcmKey } from "@/utils/crypto/symmetric"; +import E2EEWorker from "./e2eeWorker?worker"; +import { rotateCallSessionKey, createSharedSecretAndDeriveSessionKey } from "./encryption"; export interface WebRTCCall { peerConnection: RTCPeerConnection; @@ -18,6 +16,12 @@ export interface WebRTCCall { remoteUsername: string; isEnding?: boolean; isMuted?: boolean; + // Insertable Streams E2EE + sessionKey?: Uint8Array | null; + sessionCryptoKey?: CryptoKey | null; + sessionId: string; + keyRotationTimer?: NodeJS.Timeout; + lastKeyRotation?: number; } // Global state @@ -83,6 +87,7 @@ async function getIceServers(): Promise { return defaultIceServers; } + async function createPeerConnection(userId: number): Promise { const iceServers = await getIceServers(); @@ -97,7 +102,10 @@ async function createPeerConnection(userId: number): Promise isInitiator: false, remoteUserId: userId, remoteUsername: "", - isMuted: false + isMuted: false, + sessionKey: null, + sessionCryptoKey: null, + sessionId: crypto.randomUUID() }; calls.set(userId, call); @@ -197,6 +205,17 @@ export async function initiateCall(userId: number, username: string): Promise call.peerConnection.addTrack(track, localStream)); + // Enable insertable streams encryption on sender side if supported + try { + if (call.peerConnection.getSenders && call.peerConnection.getSenders().length > 0 && window.RTCRtpScriptTransform) { + const senders = call.peerConnection.getSenders(); + for (const sender of senders) { + if (!sender.track || sender.track.kind !== "audio") continue; + // just mark; actual key set after wrap/send + } + } + } catch {} + // Send call invite await sendSignalingMessage({ type: "call_invite", @@ -213,6 +232,133 @@ export async function initiateCall(userId: number, username: string): Promise { + try { + await sendSignalingMessage({ + type: "call_session_key", + fromUserId: 0, // Will be set by server + toUserId: userId, + sessionKeyHash, + data: {} + }); + } catch (error) { + console.error("Failed to send call session key:", error); + } +} + +export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise { + if (!authToken) throw new Error("No auth token available"); + try { + const recipientPublicKey = await fetchUserPublicKey(userId, authToken); + if (!recipientPublicKey) { + console.warn("No recipient public key for", userId); + return; + } + const wrapped = await wrapCallSessionKeyForRecipient(recipientPublicKey, sessionKey); + await sendSignalingMessage({ + type: "call_session_key", + fromUserId: 0, + toUserId: userId, + sessionKeyHash, + data: { wrappedSessionKey: wrapped } + }); + } catch (e) { + console.error("Failed to send wrapped session key:", e); + } +} + +async function applyE2EETransforms(call: WebRTCCall): Promise { + try { + // @ts-ignore + if (!call.sessionKey || !window.RTCRtpScriptTransform) return; + const key = await importAesGcmKey(call.sessionKey); + call.sessionCryptoKey = key; + const receiver = call.peerConnection.getReceivers().find(r => r.track && r.track.kind === 'audio'); + if (receiver) { + // @ts-ignore + receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt' }); + } + const sender = call.peerConnection.getSenders().find(s => s.track && s.track.kind === 'audio'); + if (sender) { + // @ts-ignore + sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'encrypt' }); + } + } catch {} +} + +export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promise { + const call = calls.get(userId); + if (!call) return; + call.sessionKey = keyBytes; + call.lastKeyRotation = Date.now(); + await applyE2EETransforms(call); + + // Start key rotation timer (rotate every 10 minutes for long calls) + if (call.keyRotationTimer) { + clearInterval(call.keyRotationTimer); + } + + call.keyRotationTimer = setInterval(async () => { + await rotateSessionKey(userId); + }, 10 * 60 * 1000); // 10 minutes +} + +/** + * Rotate the session key for a call to provide forward secrecy + */ +async function rotateSessionKey(userId: number): Promise { + const call = calls.get(userId); + if (!call || !call.sessionKey) return; + + try { + console.log("Rotating session key for call", userId); + + // Generate new session key + const currentSessionKey = { + key: call.sessionKey, + hash: "" // We'll generate a new hash + }; + + const newSessionKey = await rotateCallSessionKey(currentSessionKey); + + // Update the call with new session key + call.sessionKey = newSessionKey.key; + call.lastKeyRotation = Date.now(); + + // Reapply E2EE transforms with new key + await applyE2EETransforms(call); + + console.log("Session key rotated successfully for call", userId); + } catch (error) { + console.error("Failed to rotate session key:", error); + } +} + +export async function receiveWrappedSessionKey(fromUserId: number, wrappedPayload: any, sessionKeyHash?: string): Promise { + if (!authToken) return; + try { + const senderPublicKey = await fetchUserPublicKey(fromUserId, authToken); + if (!senderPublicKey) return; + if (!wrappedPayload || !sessionKeyHash) return; + + // First unwrap the session key from the encrypted payload (for validation) + await unwrapCallSessionKeyFromSender(senderPublicKey, { + salt: wrappedPayload.salt, + iv2: wrappedPayload.iv2, + wrapped: wrappedPayload.wrapped + }); + + // Then derive the actual session key from the shared secret + const call = calls.get(fromUserId); + const isInitiator = call?.isInitiator ?? false; + const derivedSessionKey = await createSharedSecretAndDeriveSessionKey(senderPublicKey, sessionKeyHash, isInitiator); + + await setSessionKey(fromUserId, derivedSessionKey.key); + } catch (e) { + console.error("Failed to unwrap session key:", e); + } +} + export async function acceptCall(userId: number): Promise { try { let call = calls.get(userId); @@ -311,6 +457,25 @@ export async function onRemoteAccepted(userId: number): Promise { } } +async function createE2EETransform(sessionKey: NonNullable, peerConnection: RTCPeerConnection, sessionId?: string): Promise { + try { + if (sessionKey && window.RTCRtpScriptTransform) { + const key = await importAesGcmKey(sessionKey); + const receiver = peerConnection.getReceivers().find(r => r.track && r.track.kind === 'audio'); + if (receiver) { + receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId }); + } + const sender = peerConnection.getSenders().find(s => s.track && s.track.kind === 'audio'); + if (sender) { + sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'encrypt', sessionId }); + } + } + } catch (error) { + console.error("Failed to create E2EE transform:", error); + throw error; + } +} + export async function handleCallOffer(userId: number, offer: RTCSessionDescriptionInit): Promise { const call = calls.get(userId); if (!call) { @@ -325,6 +490,9 @@ export async function handleCallOffer(userId: number, offer: RTCSessionDescripti const answer = await call.peerConnection.createAnswer(); await call.peerConnection.setLocalDescription(answer); + // Attach transforms on callee side if session key set and insertable streams supported + await createE2EETransform(call.sessionKey!, call.peerConnection, call.sessionId); + // Send answer to remote peer await sendSignalingMessage({ type: "call_answer", @@ -346,6 +514,8 @@ export async function handleCallAnswer(userId: number, answer: RTCSessionDescrip try { await call.peerConnection.setRemoteDescription(answer); + // After signaling completes, attach transforms on initiator side if supported + await createE2EETransform(call.sessionKey!, call.peerConnection, call.sessionId); } catch (error) { console.error("Failed to handle answer:", error); throw error; @@ -449,6 +619,11 @@ export function getCall(userId: number): WebRTCCall | undefined { export function cleanupCall(userId: number): void { const call = calls.get(userId); if (call) { + // Clear key rotation timer + if (call.keyRotationTimer) { + clearInterval(call.keyRotationTimer); + } + // Close peer connection if (call.peerConnection) { call.peerConnection.close(); diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 0181e4a..7e34bfb 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -442,9 +442,12 @@ export interface CallInvite extends CallSignalingData { timestamp: string; } -export interface CallSignalingData { - type: "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject"; +export type CallSignalingDataType = "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject" | "call_session_key" | "call_signaling"; + +export interface CallSignalingMessage extends WebSocketMessage { + type: CallSignalingDataType; fromUserId: number; toUserId: number; - data?: any; + sessionKeyHash?: string; + data: any; } \ No newline at end of file diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index f8120c8..8784694 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -6,7 +6,7 @@ */ import { API_WS_BASE_URL } from "./config"; -import type { WebSocketMessage } from "./types"; +import type { CallSignalingMessage, WebSocketMessage } from "./types"; import { delay } from "@/utils/utils"; import { CallSignalingHandler } from "@/core/calls/signaling"; @@ -109,7 +109,7 @@ async function onError() { websocket.addEventListener("message", (e) => { try { - const response: WebSocketMessage = JSON.parse(e.data); + const response: CallSignalingMessage = JSON.parse(e.data); // Handle call signaling messages if (callSignalingHandler && response.type === "call_signaling") { diff --git a/frontend/src/pages/chat/css/_callWindow.scss b/frontend/src/pages/chat/css/_callWindow.scss index 4614ebd..465faaa 100644 --- a/frontend/src/pages/chat/css/_callWindow.scss +++ b/frontend/src/pages/chat/css/_callWindow.scss @@ -162,6 +162,24 @@ color: $color-dark-on-surface-variant; font-weight: 500; } + + .encryption-emojis { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 8px; + + .encryption-emoji { + font-size: 20px; + display: inline-block; + animation: emoji-pulse 2s ease-in-out infinite; + + &:nth-child(1) { animation-delay: 0s; } + &:nth-child(2) { animation-delay: 0.2s; } + &:nth-child(3) { animation-delay: 0.4s; } + &:nth-child(4) { animation-delay: 0.6s; } + } + } } } } @@ -375,4 +393,15 @@ 100% { left: 100%; } +} + +@keyframes emoji-pulse { + 0%, 100% { + transform: scale(1); + opacity: 0.8; + } + 50% { + transform: scale(1.2); + opacity: 1; + } } \ No newline at end of file diff --git a/frontend/src/pages/chat/hooks/useAudioCall.ts b/frontend/src/pages/chat/hooks/useAudioCall.ts index b5525f9..8a83532 100644 --- a/frontend/src/pages/chat/hooks/useAudioCall.ts +++ b/frontend/src/pages/chat/hooks/useAudioCall.ts @@ -1,14 +1,15 @@ -import { useAppState } from "../state"; +import { useAppState } from "@/pages/chat/state"; import * as WebRTC from "@/core/calls/webrtc"; import { CallSignalingHandler } from "@/core/calls/signaling"; import { setCallSignalingHandler } from "@/core/websocket"; +import { generateCallSessionKey, generateCallEmojis, createCallSessionKeyFromHash } from "@/core/calls/encryption"; import { createRef, useEffect } from "react"; // Global audio ref shared across all instances let globalRemoteAudioRef = createRef(); export default function useAudioCall() { - const { chat, startCall, endCall, setCallStatus, toggleMute, user } = useAppState(); + const { chat, startCall, endCall, setCallStatus, toggleMute, setCallEncryption, setCallSessionKeyHash, user } = useAppState(); const remoteAudioRef = globalRemoteAudioRef; useEffect(() => { @@ -23,7 +24,8 @@ export default function useAudioCall() { const state = useAppState.getState(); state.receiveCall(userId, username); }, - endCall + endCall, + setCallSessionKeyHash })); setCallSignalingHandler(signalingHandler); @@ -85,6 +87,14 @@ export default function useAudioCall() { }; }, [user.authToken, chat.call.remoteUserId, setCallStatus, endCall, startCall]); + // Watch for session key hash changes and generate emojis + useEffect(() => { + if (chat.call.sessionKeyHash && chat.call.encryptionEmojis.length === 0) { + const emojis = generateCallEmojis(chat.call.sessionKeyHash); + setCallEncryption(chat.call.sessionKeyHash, emojis); + } + }, [chat.call.sessionKeyHash, chat.call.encryptionEmojis.length, setCallEncryption]); + async function requestAudioPermissions(): Promise { try { const stream = await navigator.mediaDevices.getUserMedia({ @@ -110,14 +120,31 @@ export default function useAudioCall() { } - // Start the call in state - startCall(userId, username); - setCallStatus("calling"); + let sessionKey; + try { + // Generate call session key and emojis + sessionKey = await generateCallSessionKey(); + const emojis = generateCallEmojis(sessionKey.hash); + + // Start the call in state + startCall(userId, username); + setCallStatus("calling"); + setCallEncryption(sessionKey.hash, emojis); + } catch (error) { + console.error("Failed to generate call encryption:", error); + endCall(); + return; + } // Initiate WebRTC call const success = await WebRTC.initiateCall(userId, username); - if (!success) { + if (success && sessionKey) { + // Send session key hash to the receiver for visual verification + await WebRTC.sendCallSessionKey(userId, sessionKey.hash); + // Also wrap and send the actual session key for E2EE media + await WebRTC.sendWrappedCallSessionKey(userId, sessionKey.key, sessionKey.hash); + } else { endCall(); } }; @@ -162,6 +189,7 @@ export default function useAudioCall() { }; async function handleIncomingCall(userId: number, username: string) { + // Don't generate session key here - wait for it from the caller await WebRTC.handleIncomingCall(userId, username); }; @@ -177,6 +205,17 @@ export default function useAudioCall() { await WebRTC.handleIceCandidate(userId, candidate); }; + async function handleCallSessionKey(sessionKeyHash: string) { + try { + // Create session key from the hash provided by the caller + const sessionKey = await createCallSessionKeyFromHash(sessionKeyHash); + const emojis = generateCallEmojis(sessionKey.hash); + setCallEncryption(sessionKey.hash, emojis); + } catch (error) { + console.error("Failed to create call session key from hash:", error); + } + }; + return { call: chat.call, initiateCall, @@ -188,6 +227,7 @@ export default function useAudioCall() { handleCallOffer, handleCallAnswer, handleIceCandidate, + handleCallSessionKey, remoteAudioRef }; } diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts index 099e1c4..1c947ac 100644 --- a/frontend/src/pages/chat/state.ts +++ b/frontend/src/pages/chat/state.ts @@ -29,6 +29,8 @@ interface CallState { remoteUsername: string | null; isInitiator: boolean; isMinimized: boolean; + sessionKeyHash: string | null; + encryptionEmojis: string[]; } interface ChatState { @@ -75,6 +77,8 @@ interface AppState { toggleMute: () => void; toggleCallMinimize: () => void; receiveCall: (userId: number, username: string) => void; + setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => void; + setCallSessionKeyHash: (sessionKeyHash: string) => void; // User state user: UserState; @@ -110,7 +114,9 @@ export const useAppState = create((set, get) => ({ remoteUserId: null, remoteUsername: null, isInitiator: false, - isMinimized: false + isMinimized: false, + sessionKeyHash: null, + encryptionEmojis: [] } }, addMessage: (message: Message) => set((state) => { @@ -404,7 +410,9 @@ export const useAppState = create((set, get) => ({ remoteUserId: userId, remoteUsername: username, isInitiator: true, - isMinimized: false + isMinimized: false, + sessionKeyHash: null, + encryptionEmojis: [] } } })), @@ -420,7 +428,9 @@ export const useAppState = create((set, get) => ({ remoteUserId: null, remoteUsername: null, isInitiator: false, - isMinimized: false + isMinimized: false, + sessionKeyHash: null, + encryptionEmojis: [] } } })), @@ -467,7 +477,30 @@ export const useAppState = create((set, get) => ({ remoteUserId: userId, remoteUsername: username, isInitiator: false, - isMinimized: false + isMinimized: false, + sessionKeyHash: null, + encryptionEmojis: [] + } + } + })), + + setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => set((state) => ({ + chat: { + ...state.chat, + call: { + ...state.chat.call, + sessionKeyHash, + encryptionEmojis + } + } + })), + + setCallSessionKeyHash: (sessionKeyHash: string) => set((state) => ({ + chat: { + ...state.chat, + call: { + ...state.chat.call, + sessionKeyHash } } }))