Implement call encryption

Security fixes
This commit is contained in:
2025-10-01 22:50:32 +03:00
Unverified
parent a424ea009d
commit 1b2f825a74
9 changed files with 657 additions and 27 deletions
+100
View File
@@ -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<EncodedFrame>) {
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);
});
+236
View File
@@ -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<CallSessionKey> {
// 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<CallSessionKey> {
// 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<CallSessionKey> {
// 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<CallSessionKey> {
// 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<EncryptedCallMessage> {
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<any> {
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<WrappedSessionKeyPayload> {
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<CallSessionKey> {
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<Uint8Array> {
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);
}
+16 -2
View File
@@ -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<CallSignalingData>) {
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);
}
}
}
+184 -9
View File
@@ -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<RTCIceServer[]> {
return defaultIceServers;
}
async function createPeerConnection(userId: number): Promise<RTCPeerConnection> {
const iceServers = await getIceServers();
@@ -97,7 +102,10 @@ async function createPeerConnection(userId: number): Promise<RTCPeerConnection>
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<bo
// Add tracks to peer connection
localStream.getTracks().forEach(track => 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<bo
}
}
export async function sendCallSessionKey(userId: number, sessionKeyHash: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
try {
let call = calls.get(userId);
@@ -311,6 +457,25 @@ export async function onRemoteAccepted(userId: number): Promise<void> {
}
}
async function createE2EETransform(sessionKey: NonNullable<WebRTCCall['sessionKey']>, peerConnection: RTCPeerConnection, sessionId?: string): Promise<void> {
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<void> {
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();
+6 -3
View File
@@ -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;
}
+2 -2
View File
@@ -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<any> = JSON.parse(e.data);
const response: CallSignalingMessage = JSON.parse(e.data);
// Handle call signaling messages
if (callSignalingHandler && response.type === "call_signaling") {
@@ -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; }
}
}
}
}
}
@@ -376,3 +394,14 @@
left: 100%;
}
}
@keyframes emoji-pulse {
0%, 100% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.2);
opacity: 1;
}
}
+44 -4
View File
@@ -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<HTMLAudioElement>();
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<boolean> {
try {
const stream = await navigator.mediaDevices.getUserMedia({
@@ -110,14 +120,31 @@ export default function useAudioCall() {
}
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
};
}
+37 -4
View File
@@ -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<AppState>((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<AppState>((set, get) => ({
remoteUserId: userId,
remoteUsername: username,
isInitiator: true,
isMinimized: false
isMinimized: false,
sessionKeyHash: null,
encryptionEmojis: []
}
}
})),
@@ -420,7 +428,9 @@ export const useAppState = create<AppState>((set, get) => ({
remoteUserId: null,
remoteUsername: null,
isInitiator: false,
isMinimized: false
isMinimized: false,
sessionKeyHash: null,
encryptionEmojis: []
}
}
})),
@@ -467,7 +477,30 @@ export const useAppState = create<AppState>((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
}
}
}))