mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement call encryption
Security fixes
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
+6
-3
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user