mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement video encryption
This commit is contained in:
@@ -1,11 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* E2EE Worker for WebRTC Insertable Streams
|
* E2EE Worker for WebRTC Insertable Streams
|
||||||
* Encrypts/decrypts encoded audio and video frames using AES-GCM
|
* Encrypts/decrypts encoded audio and video frames using AES-GCM
|
||||||
* Optimized for both small audio frames and large video frames
|
* Uses RTP timestamps for IVs to handle out-of-order and dropped frames
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface FrameMetadata {
|
||||||
|
contributingSources?: number[];
|
||||||
|
mimeType?: string;
|
||||||
|
payloadType?: number;
|
||||||
|
rtpTimestamp: number;
|
||||||
|
synchronizationSource: number;
|
||||||
|
dependencies?: number[];
|
||||||
|
frameId?: number;
|
||||||
|
spatialIndex?: number;
|
||||||
|
temporalIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EncodedFrame {
|
export interface EncodedFrame {
|
||||||
data: Uint8Array;
|
data: Uint8Array | ArrayBuffer;
|
||||||
|
timestamp?: number;
|
||||||
|
type?: string;
|
||||||
|
getMetadata?: () => FrameMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkerOptions {
|
export interface WorkerOptions {
|
||||||
@@ -14,28 +29,75 @@ export interface WorkerOptions {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract sequence number from encoded frame
|
||||||
|
* For RTCEncodedVideoFrame/AudioFrame, we use the frame's metadata if available,
|
||||||
|
* otherwise fall back to extracting from RTP header
|
||||||
|
*/
|
||||||
|
function makeIV(encodedFrame: EncodedFrame, frameCount: number): ArrayBuffer {
|
||||||
|
// Debug: log frame properties
|
||||||
|
if (frameCount <= 3) {
|
||||||
|
console.log("Frame object keys:", Object.keys(encodedFrame));
|
||||||
|
console.log("Frame timestamp:", encodedFrame.timestamp);
|
||||||
|
if (encodedFrame.getMetadata) {
|
||||||
|
const metadata = encodedFrame.getMetadata();
|
||||||
|
console.log("Metadata:", metadata);
|
||||||
|
console.log("RTP timestamp:", metadata?.rtpTimestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use RTP timestamp from metadata as IV base
|
||||||
|
// This is synchronized between sender and receiver
|
||||||
|
const ivBuffer = new ArrayBuffer(12);
|
||||||
|
|
||||||
|
if (encodedFrame.getMetadata) {
|
||||||
|
try {
|
||||||
|
const metadata = encodedFrame.getMetadata();
|
||||||
|
if (metadata && typeof metadata.rtpTimestamp === 'number') {
|
||||||
|
// Use RTP timestamp as IV - it's synchronized between peers
|
||||||
|
const view = new DataView(ivBuffer);
|
||||||
|
view.setUint32(0, metadata.rtpTimestamp, false); // First 4 bytes
|
||||||
|
view.setUint32(8, metadata.synchronizationSource || 0, false); // Last 4 bytes
|
||||||
|
return ivBuffer;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to get metadata:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use frame count (not ideal but better than nothing)
|
||||||
|
const view = new DataView(ivBuffer);
|
||||||
|
view.setUint32(8, frameCount, false);
|
||||||
|
return ivBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
addEventListener("rtctransform", (event) => {
|
addEventListener("rtctransform", (event) => {
|
||||||
const { transformer } = event;
|
const { transformer } = event;
|
||||||
const { readable, writable } = transformer;
|
const { readable, writable } = transformer;
|
||||||
const { key, mode } = transformer.options as WorkerOptions;
|
const { key, mode } = transformer.options as WorkerOptions;
|
||||||
|
|
||||||
// Use a synchronized counter for IV generation
|
|
||||||
// Both sides must start from the same point for the same session
|
|
||||||
let frameCounter = 0;
|
|
||||||
|
|
||||||
// Track sender vs receiver side independently
|
|
||||||
// Each side maintains its own counter
|
|
||||||
const isEncrypting = mode === 'encrypt';
|
const isEncrypting = mode === 'encrypt';
|
||||||
|
|
||||||
|
let frameCount = 0;
|
||||||
|
let lastLogTime = 0;
|
||||||
|
|
||||||
async function transform(encodedFrame: EncodedFrame, controller: TransformStreamDefaultController<EncodedFrame>) {
|
async function transform(encodedFrame: EncodedFrame, controller: TransformStreamDefaultController<EncodedFrame>) {
|
||||||
try {
|
try {
|
||||||
// Create a unique IV for each frame using the counter
|
|
||||||
// Format: 12 bytes total = 8 bytes of zeros + 4 bytes counter
|
|
||||||
const iv = new Uint8Array(12);
|
|
||||||
const view = new DataView(iv.buffer);
|
|
||||||
view.setUint32(8, frameCounter++, false); // Big-endian counter
|
|
||||||
|
|
||||||
const data = new Uint8Array(encodedFrame.data);
|
const data = new Uint8Array(encodedFrame.data);
|
||||||
|
|
||||||
|
// Increment frame counter
|
||||||
|
frameCount++;
|
||||||
|
|
||||||
|
// Create IV using RTP timestamp from metadata (synchronized between peers)
|
||||||
|
const iv = makeIV(encodedFrame, frameCount);
|
||||||
|
|
||||||
|
// Log first few frames and periodically for debugging
|
||||||
|
const now = Date.now();
|
||||||
|
if (frameCount <= 5 || now - lastLogTime > 5000) {
|
||||||
|
console.log(`E2EE ${mode} frame #${frameCount}, size: ${data.length} bytes`);
|
||||||
|
lastLogTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
const params: AesGcmParams = { name: 'AES-GCM', iv };
|
const params: AesGcmParams = { name: 'AES-GCM', iv };
|
||||||
|
|
||||||
let result: ArrayBuffer;
|
let result: ArrayBuffer;
|
||||||
@@ -48,23 +110,18 @@ addEventListener("rtctransform", (event) => {
|
|||||||
result = await crypto.subtle.decrypt(params, key, data);
|
result = await crypto.subtle.decrypt(params, key, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update frame data with encrypted/decrypted result
|
// CRITICAL: Video frames need ArrayBuffer, not Uint8Array
|
||||||
encodedFrame.data = new Uint8Array(result);
|
// Must assign the buffer directly, not wrapped in Uint8Array
|
||||||
|
encodedFrame.data = result;
|
||||||
controller.enqueue(encodedFrame);
|
controller.enqueue(encodedFrame);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Log error but don't stop the stream - allows graceful degradation
|
// FAIL SECURELY: Never send unencrypted frames
|
||||||
console.error(`E2EE ${mode} failed for frame ${frameCounter}:`, e);
|
const data = new Uint8Array(encodedFrame.data);
|
||||||
|
console.error(`E2EE ${mode} FAILED - dropping frame #${frameCount}, size: ${data.length}`, e);
|
||||||
// For decryption errors, we can't recover - must drop the frame
|
console.error('Frame type:', encodedFrame.type || 'unknown');
|
||||||
if (!isEncrypting) {
|
// Drop the frame completely - don't enqueue anything
|
||||||
// Just drop the frame silently to avoid breaking the stream
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For encryption errors, pass through unencrypted as last resort
|
|
||||||
console.warn("Passing through unencrypted frame due to encryption failure");
|
|
||||||
controller.enqueue(encodedFrame);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,26 +138,32 @@ export class CallSignalingHandler {
|
|||||||
private handleVideoToggle(data: any) {
|
private handleVideoToggle(data: any) {
|
||||||
console.log("handleVideoToggle called with data:", data);
|
console.log("handleVideoToggle called with data:", data);
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
const { data: toggleData } = data;
|
const { fromUserId, data: toggleData } = data;
|
||||||
|
|
||||||
if (toggleData && typeof toggleData.enabled === "boolean") {
|
if (toggleData && typeof toggleData.enabled === "boolean" && fromUserId) {
|
||||||
console.log("Setting remote video enabled to:", toggleData.enabled);
|
console.log("Setting remote video enabled to:", toggleData.enabled);
|
||||||
|
// Update Zustand state (for UI)
|
||||||
state.setRemoteVideoEnabled(toggleData.enabled);
|
state.setRemoteVideoEnabled(toggleData.enabled);
|
||||||
|
// Update WebRTC internal state (for track routing)
|
||||||
|
WebRTC.setRemoteVideoEnabled(fromUserId, toggleData.enabled);
|
||||||
} else {
|
} else {
|
||||||
console.warn("Invalid toggle data:", toggleData);
|
console.warn("Invalid toggle data:", data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleScreenShareToggle(data: any) {
|
private handleScreenShareToggle(data: any) {
|
||||||
console.log("handleScreenShareToggle called with data:", data);
|
console.log("handleScreenShareToggle called with data:", data);
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
const { data: toggleData } = data;
|
const { fromUserId, data: toggleData } = data;
|
||||||
|
|
||||||
if (toggleData && typeof toggleData.enabled === "boolean") {
|
if (toggleData && typeof toggleData.enabled === "boolean" && fromUserId) {
|
||||||
console.log("Setting remote screen sharing to:", toggleData.enabled);
|
console.log("Setting remote screen sharing to:", toggleData.enabled);
|
||||||
|
// Update Zustand state (for UI)
|
||||||
state.setRemoteScreenSharing(toggleData.enabled);
|
state.setRemoteScreenSharing(toggleData.enabled);
|
||||||
|
// Update WebRTC internal state (for track routing)
|
||||||
|
WebRTC.setRemoteScreenSharing(fromUserId, toggleData.enabled);
|
||||||
} else {
|
} else {
|
||||||
console.warn("Invalid toggle data:", toggleData);
|
console.warn("Invalid toggle data:", data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface WebRTCCall {
|
|||||||
isMuted?: boolean;
|
isMuted?: boolean;
|
||||||
isLocalVideoEnabled: boolean;
|
isLocalVideoEnabled: boolean;
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
|
isRemoteScreenSharing: boolean; // Track remote screen share state from signaling
|
||||||
|
isRemoteVideoEnabled: boolean; // Track remote video state from signaling
|
||||||
isNegotiating?: boolean;
|
isNegotiating?: boolean;
|
||||||
// Insertable Streams E2EE
|
// Insertable Streams E2EE
|
||||||
sessionKey?: Uint8Array | null;
|
sessionKey?: Uint8Array | null;
|
||||||
@@ -26,6 +28,8 @@ export interface WebRTCCall {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
keyRotationTimer?: NodeJS.Timeout;
|
keyRotationTimer?: NodeJS.Timeout;
|
||||||
lastKeyRotation?: number;
|
lastKeyRotation?: number;
|
||||||
|
transformedSenders: Set<RTCRtpSender>;
|
||||||
|
transformedReceivers: Set<RTCRtpReceiver>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global state
|
// Global state
|
||||||
@@ -131,9 +135,13 @@ async function createPeerConnection(userId: number): Promise<RTCPeerConnection>
|
|||||||
isMuted: false,
|
isMuted: false,
|
||||||
isLocalVideoEnabled: false,
|
isLocalVideoEnabled: false,
|
||||||
isScreenSharing: false,
|
isScreenSharing: false,
|
||||||
|
isRemoteScreenSharing: false,
|
||||||
|
isRemoteVideoEnabled: false,
|
||||||
sessionKey: null,
|
sessionKey: null,
|
||||||
sessionCryptoKey: null,
|
sessionCryptoKey: null,
|
||||||
sessionId: crypto.randomUUID()
|
sessionId: crypto.randomUUID(),
|
||||||
|
transformedSenders: new Set(),
|
||||||
|
transformedReceivers: new Set()
|
||||||
};
|
};
|
||||||
|
|
||||||
calls.set(userId, call);
|
calls.set(userId, call);
|
||||||
@@ -231,36 +239,42 @@ async function createPeerConnection(userId: number): Promise<RTCPeerConnection>
|
|||||||
const track = event.track;
|
const track = event.track;
|
||||||
|
|
||||||
// Apply E2EE transform to the receiver for this new track if session key is available
|
// Apply E2EE transform to the receiver for this new track if session key is available
|
||||||
// Currently disabled for video tracks due to counter synchronization issues
|
if (call.sessionKey && window.RTCRtpScriptTransform) {
|
||||||
if (call.sessionKey && window.RTCRtpScriptTransform && track.kind === "audio") {
|
|
||||||
try {
|
try {
|
||||||
const key = await importAesGcmKey(call.sessionKey);
|
|
||||||
const receiver = call.peerConnection.getReceivers().find(r => r.track === track);
|
const receiver = call.peerConnection.getReceivers().find(r => r.track === track);
|
||||||
if (receiver) {
|
if (receiver && !call.transformedReceivers.has(receiver)) {
|
||||||
|
const key = await importAesGcmKey(call.sessionKey);
|
||||||
console.log(`Applying decrypt transform to newly received ${track.kind} track`);
|
console.log(`Applying decrypt transform to newly received ${track.kind} track`);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId: call.sessionId });
|
receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId: call.sessionId });
|
||||||
|
call.transformedReceivers.add(receiver);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to apply E2EE to received track:", error);
|
console.error("Failed to apply E2EE to received track:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine stream type based on track kind and stream ID
|
// Determine stream type based on track kind and signaling state
|
||||||
const streamId = remoteStream.id;
|
console.log(`Track received: kind=${track.kind}, isRemoteScreenSharing=${call.isRemoteScreenSharing}, isRemoteVideoEnabled=${call.isRemoteVideoEnabled}`);
|
||||||
|
|
||||||
// Check if this is a screen share stream (we'll use a convention: screen share streams have "screen" in their ID)
|
if (track.kind === "video") {
|
||||||
if (streamId.includes("screen")) {
|
// Prioritize screen share over regular video
|
||||||
console.log("Detected screen share track, notifying handler");
|
// If remote is screen sharing, this video track is the screen share
|
||||||
// Handle remote screen share
|
if (call.isRemoteScreenSharing) {
|
||||||
if (onRemoteScreenShare) {
|
console.log("Detected screen share track (based on signaling), notifying handler");
|
||||||
onRemoteScreenShare(userId, remoteStream);
|
if (onRemoteScreenShare) {
|
||||||
}
|
onRemoteScreenShare(userId, remoteStream);
|
||||||
} else if (track.kind === "video") {
|
} else {
|
||||||
console.log("Detected video track, notifying handler");
|
console.warn("onRemoteScreenShare handler not set!");
|
||||||
// Handle remote video
|
}
|
||||||
if (onRemoteVideoStream) {
|
} else {
|
||||||
onRemoteVideoStream(userId, remoteStream);
|
console.log("Detected video track (based on signaling), notifying handler");
|
||||||
|
// Handle remote video
|
||||||
|
if (onRemoteVideoStream) {
|
||||||
|
onRemoteVideoStream(userId, remoteStream);
|
||||||
|
} else {
|
||||||
|
console.warn("onRemoteVideoStream handler not set!");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (track.kind === "audio") {
|
} else if (track.kind === "audio") {
|
||||||
console.log("Detected audio track, notifying handler");
|
console.log("Detected audio track, notifying handler");
|
||||||
@@ -268,6 +282,8 @@ async function createPeerConnection(userId: number): Promise<RTCPeerConnection>
|
|||||||
call.remoteStream = remoteStream;
|
call.remoteStream = remoteStream;
|
||||||
if (onRemoteStream) {
|
if (onRemoteStream) {
|
||||||
onRemoteStream(userId, remoteStream);
|
onRemoteStream(userId, remoteStream);
|
||||||
|
} else {
|
||||||
|
console.warn("onRemoteStream handler not set!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,23 +409,25 @@ async function applyE2EETransforms(call: WebRTCCall): Promise<void> {
|
|||||||
const key = await importAesGcmKey(call.sessionKey);
|
const key = await importAesGcmKey(call.sessionKey);
|
||||||
call.sessionCryptoKey = key;
|
call.sessionCryptoKey = key;
|
||||||
|
|
||||||
// Apply to audio receivers only (video E2EE requires synchronized counters)
|
// Apply to receivers that don't already have transforms
|
||||||
const receivers = call.peerConnection.getReceivers();
|
const receivers = call.peerConnection.getReceivers();
|
||||||
for (const receiver of receivers) {
|
for (const receiver of receivers) {
|
||||||
if (receiver.track && receiver.track.kind === "audio") {
|
if (receiver.track && !call.transformedReceivers.has(receiver)) {
|
||||||
console.log(`Applying decrypt transform to ${receiver.track.kind} receiver`);
|
console.log(`Applying decrypt transform to ${receiver.track.kind} receiver`);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId: call.sessionId });
|
receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId: call.sessionId });
|
||||||
|
call.transformedReceivers.add(receiver);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply to audio senders only (video E2EE requires synchronized counters)
|
// Apply to senders that don't already have transforms
|
||||||
const senders = call.peerConnection.getSenders();
|
const senders = call.peerConnection.getSenders();
|
||||||
for (const sender of senders) {
|
for (const sender of senders) {
|
||||||
if (sender.track && sender.track.kind === "audio") {
|
if (sender.track && !call.transformedSenders.has(sender)) {
|
||||||
console.log(`Applying encrypt transform to ${sender.track.kind} sender`);
|
console.log(`Applying encrypt transform to ${sender.track.kind} sender`);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'encrypt', sessionId: call.sessionId });
|
sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'encrypt', sessionId: call.sessionId });
|
||||||
|
call.transformedSenders.add(sender);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -419,10 +437,18 @@ async function applyE2EETransforms(call: WebRTCCall): Promise<void> {
|
|||||||
|
|
||||||
export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promise<void> {
|
export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promise<void> {
|
||||||
const call = calls.get(userId);
|
const call = calls.get(userId);
|
||||||
if (!call) return;
|
if (!call) {
|
||||||
|
console.error("setSessionKey: No call found for user", userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("setSessionKey: Setting session key for user", userId);
|
||||||
call.sessionKey = keyBytes;
|
call.sessionKey = keyBytes;
|
||||||
call.lastKeyRotation = Date.now();
|
call.lastKeyRotation = Date.now();
|
||||||
|
|
||||||
|
console.log("setSessionKey: Applying E2EE transforms...");
|
||||||
await applyE2EETransforms(call);
|
await applyE2EETransforms(call);
|
||||||
|
console.log("setSessionKey: E2EE transforms applied successfully");
|
||||||
|
|
||||||
// Start key rotation timer (rotate every 10 minutes for long calls)
|
// Start key rotation timer (rotate every 10 minutes for long calls)
|
||||||
if (call.keyRotationTimer) {
|
if (call.keyRotationTimer) {
|
||||||
@@ -468,10 +494,18 @@ async function rotateSessionKey(userId: number): Promise<void> {
|
|||||||
export async function receiveWrappedSessionKey(fromUserId: number, wrappedPayload: any, sessionKeyHash?: string): Promise<void> {
|
export async function receiveWrappedSessionKey(fromUserId: number, wrappedPayload: any, sessionKeyHash?: string): Promise<void> {
|
||||||
if (!authToken) return;
|
if (!authToken) return;
|
||||||
try {
|
try {
|
||||||
|
console.log("receiveWrappedSessionKey called for user", fromUserId, "hash:", sessionKeyHash);
|
||||||
const senderPublicKey = await fetchUserPublicKey(fromUserId, authToken);
|
const senderPublicKey = await fetchUserPublicKey(fromUserId, authToken);
|
||||||
if (!senderPublicKey) return;
|
if (!senderPublicKey) {
|
||||||
if (!wrappedPayload || !sessionKeyHash) return;
|
console.error("Failed to get sender public key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!wrappedPayload || !sessionKeyHash) {
|
||||||
|
console.error("Missing wrapped payload or session key hash");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Unwrapping session key...");
|
||||||
// Unwrap the session key from the encrypted payload
|
// Unwrap the session key from the encrypted payload
|
||||||
const unwrappedSessionKey = await unwrapCallSessionKeyFromSender(senderPublicKey, {
|
const unwrappedSessionKey = await unwrapCallSessionKeyFromSender(senderPublicKey, {
|
||||||
salt: wrappedPayload.salt,
|
salt: wrappedPayload.salt,
|
||||||
@@ -479,8 +513,10 @@ export async function receiveWrappedSessionKey(fromUserId: number, wrappedPayloa
|
|||||||
wrapped: wrappedPayload.wrapped
|
wrapped: wrappedPayload.wrapped
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("Session key unwrapped successfully, setting it...");
|
||||||
// Use the unwrapped session key directly (both sides should have the same key)
|
// Use the unwrapped session key directly (both sides should have the same key)
|
||||||
await setSessionKey(fromUserId, unwrappedSessionKey);
|
await setSessionKey(fromUserId, unwrappedSessionKey);
|
||||||
|
console.log("Session key set successfully for user", fromUserId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to unwrap session key:", e);
|
console.error("Failed to unwrap session key:", e);
|
||||||
}
|
}
|
||||||
@@ -590,7 +626,7 @@ export async function onRemoteAccepted(userId: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createE2EETransform(sessionKey: NonNullable<WebRTCCall['sessionKey']>, peerConnection: RTCPeerConnection, sessionId?: string): Promise<void> {
|
async function createE2EETransform(call: WebRTCCall, sessionKey: Uint8Array, sessionId?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (!sessionKey || !window.RTCRtpScriptTransform) {
|
if (!sessionKey || !window.RTCRtpScriptTransform) {
|
||||||
@@ -601,28 +637,31 @@ async function createE2EETransform(sessionKey: NonNullable<WebRTCCall['sessionKe
|
|||||||
console.log("Creating E2EE transform with sessionId:", sessionId);
|
console.log("Creating E2EE transform with sessionId:", sessionId);
|
||||||
const key = await importAesGcmKey(sessionKey);
|
const key = await importAesGcmKey(sessionKey);
|
||||||
|
|
||||||
// Apply to audio receivers only (video E2EE requires synchronized counters)
|
// Apply to receivers that don't already have transforms
|
||||||
const receivers = peerConnection.getReceivers();
|
const receivers = call.peerConnection.getReceivers();
|
||||||
for (const receiver of receivers) {
|
for (const receiver of receivers) {
|
||||||
if (receiver.track && receiver.track.kind === "audio") {
|
if (receiver.track && !call.transformedReceivers.has(receiver)) {
|
||||||
console.log(`Applying decrypt transform to ${receiver.track.kind} in createE2EETransform`);
|
console.log(`Applying decrypt transform to ${receiver.track.kind} in createE2EETransform`);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId });
|
receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId });
|
||||||
|
call.transformedReceivers.add(receiver);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply to audio senders only (video E2EE requires synchronized counters)
|
// Apply to senders that don't already have transforms
|
||||||
const senders = peerConnection.getSenders();
|
const senders = call.peerConnection.getSenders();
|
||||||
for (const sender of senders) {
|
for (const sender of senders) {
|
||||||
if (sender.track && sender.track.kind === "audio") {
|
if (sender.track && !call.transformedSenders.has(sender)) {
|
||||||
console.log(`Applying encrypt transform to ${sender.track.kind} in createE2EETransform`);
|
console.log(`Applying encrypt transform to ${sender.track.kind} in createE2EETransform`);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'encrypt', sessionId });
|
sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'encrypt', sessionId });
|
||||||
|
call.transformedSenders.add(sender);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create E2EE transform:", error);
|
console.error("Failed to create E2EE transform:", error);
|
||||||
// Don't throw - let the call continue without E2EE
|
// Fail securely - throw to prevent call from continuing without E2EE
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,7 +709,7 @@ export async function handleCallOffer(userId: number, offer: RTCSessionDescripti
|
|||||||
// Attach transforms on callee side if session key is available
|
// Attach transforms on callee side if session key is available
|
||||||
// If not available yet, setSessionKey will apply them when it arrives
|
// If not available yet, setSessionKey will apply them when it arrives
|
||||||
if (call.sessionKey) {
|
if (call.sessionKey) {
|
||||||
await createE2EETransform(call.sessionKey, call.peerConnection, call.sessionId);
|
await createE2EETransform(call, call.sessionKey, call.sessionId);
|
||||||
} else {
|
} else {
|
||||||
console.log("Session key not yet available in handleCallOffer - will apply transforms when key arrives");
|
console.log("Session key not yet available in handleCallOffer - will apply transforms when key arrives");
|
||||||
}
|
}
|
||||||
@@ -697,18 +736,47 @@ export async function handleCallAnswer(userId: number, answer: RTCSessionDescrip
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log("handleCallAnswer - setting remote description");
|
||||||
|
console.log("Answer SDP media lines:", answer.sdp?.split('\n').filter(l => l.includes('m=')).join(', '));
|
||||||
|
|
||||||
await call.peerConnection.setRemoteDescription(answer);
|
await call.peerConnection.setRemoteDescription(answer);
|
||||||
|
|
||||||
|
console.log("Remote description set successfully");
|
||||||
|
console.log("Current receivers after answer:", call.peerConnection.getReceivers().map(r => r.track?.kind));
|
||||||
|
console.log("Current senders after answer:", call.peerConnection.getSenders().map(s => s.track?.kind));
|
||||||
|
|
||||||
// Reset negotiating flag
|
// Reset negotiating flag
|
||||||
call.isNegotiating = false;
|
call.isNegotiating = false;
|
||||||
|
|
||||||
// Attach transforms on initiator side if session key is available
|
// Attach transforms on initiator side if session key is available
|
||||||
// If not available yet, setSessionKey will apply them when it arrives
|
// If not available yet, setSessionKey will apply them when it arrives
|
||||||
if (call.sessionKey) {
|
if (call.sessionKey) {
|
||||||
await createE2EETransform(call.sessionKey, call.peerConnection, call.sessionId);
|
await createE2EETransform(call, call.sessionKey, call.sessionId);
|
||||||
} else {
|
} else {
|
||||||
console.log("Session key not yet available in handleCallAnswer - will apply transforms when key arrives");
|
console.log("Session key not yet available in handleCallAnswer - will apply transforms when key arrives");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if there are new receivers with tracks that haven't been notified yet
|
||||||
|
// This handles the case where tracks exist but the track event hasn't fired yet
|
||||||
|
const receivers = call.peerConnection.getReceivers();
|
||||||
|
for (const receiver of receivers) {
|
||||||
|
if (receiver.track) {
|
||||||
|
const track = receiver.track;
|
||||||
|
console.log("Checking receiver track:", track.kind);
|
||||||
|
|
||||||
|
// Find the stream for this track
|
||||||
|
const transceiver = call.peerConnection.getTransceivers().find(t => t.receiver === receiver);
|
||||||
|
if (transceiver && transceiver.receiver.track) {
|
||||||
|
// Manually trigger stream handlers for tracks that didn't fire events
|
||||||
|
if (track.kind === "video" && onRemoteVideoStream) {
|
||||||
|
// Create a MediaStream from the track
|
||||||
|
const stream = new MediaStream([track]);
|
||||||
|
console.log("Manually notifying remote video stream handler");
|
||||||
|
onRemoteVideoStream(userId, stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to handle answer:", error);
|
console.error("Failed to handle answer:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -829,7 +897,7 @@ export async function toggleVideo(userId: number): Promise<boolean> {
|
|||||||
|
|
||||||
// Add video track to peer connection
|
// Add video track to peer connection
|
||||||
const videoTrack = videoStream.getVideoTracks()[0];
|
const videoTrack = videoStream.getVideoTracks()[0];
|
||||||
call.peerConnection.addTrack(videoTrack, videoStream);
|
const sender = call.peerConnection.addTrack(videoTrack, videoStream);
|
||||||
|
|
||||||
console.log("Video track added successfully");
|
console.log("Video track added successfully");
|
||||||
console.log("Current senders:", call.peerConnection.getSenders().map(s => s.track?.kind));
|
console.log("Current senders:", call.peerConnection.getSenders().map(s => s.track?.kind));
|
||||||
@@ -839,9 +907,20 @@ export async function toggleVideo(userId: number): Promise<boolean> {
|
|||||||
direction: t.direction
|
direction: t.direction
|
||||||
})));
|
})));
|
||||||
|
|
||||||
// Note: E2EE for video is temporarily disabled for testing
|
// Apply E2EE transform if session key exists and not already transformed
|
||||||
// Will re-enable with proper counter synchronization
|
if (call.sessionKey && window.RTCRtpScriptTransform && !call.transformedSenders.has(sender)) {
|
||||||
console.log("Video sent without E2EE (will implement synchronized encryption)");
|
try {
|
||||||
|
const key = await importAesGcmKey(call.sessionKey);
|
||||||
|
console.log("Applying E2EE to video sender");
|
||||||
|
sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: "encrypt", sessionId: call.sessionId });
|
||||||
|
call.transformedSenders.add(sender);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to apply E2EE to video:", error);
|
||||||
|
throw error; // Fail securely
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("Skipping E2EE for video - session key not available yet or already transformed");
|
||||||
|
}
|
||||||
|
|
||||||
// Notify local video stream handler
|
// Notify local video stream handler
|
||||||
if (onLocalVideoStream) {
|
if (onLocalVideoStream) {
|
||||||
@@ -916,10 +995,16 @@ export async function toggleScreenShare(userId: number): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set a special ID to identify screen share streams
|
// Set a special ID to identify screen share streams
|
||||||
Object.defineProperty(screenStream, "id", {
|
try {
|
||||||
value: `screen-${crypto.randomUUID()}`,
|
Object.defineProperty(screenStream, "id", {
|
||||||
writable: false
|
value: `screen-${crypto.randomUUID()}`,
|
||||||
});
|
writable: false,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
console.log("Set screen share stream ID to:", screenStream.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to set custom stream ID, using default:", screenStream.id);
|
||||||
|
}
|
||||||
|
|
||||||
call.screenShareStream = screenStream;
|
call.screenShareStream = screenStream;
|
||||||
call.isScreenSharing = true;
|
call.isScreenSharing = true;
|
||||||
@@ -932,13 +1017,25 @@ export async function toggleScreenShare(userId: number): Promise<boolean> {
|
|||||||
toggleScreenShare(userId);
|
toggleScreenShare(userId);
|
||||||
});
|
});
|
||||||
|
|
||||||
call.peerConnection.addTrack(videoTrack, screenStream);
|
const sender = call.peerConnection.addTrack(videoTrack, screenStream);
|
||||||
|
|
||||||
console.log("Screen share track added successfully");
|
console.log("Screen share track added successfully");
|
||||||
|
|
||||||
// Note: E2EE for screen share is temporarily disabled for testing
|
// Apply E2EE transform if session key exists and not already transformed
|
||||||
// Will re-enable with proper counter synchronization
|
if (call.sessionKey && window.RTCRtpScriptTransform && !call.transformedSenders.has(sender)) {
|
||||||
console.log("Screen share sent without E2EE (will implement synchronized encryption)");
|
try {
|
||||||
|
const key = await importAesGcmKey(call.sessionKey);
|
||||||
|
console.log("Applying E2EE to screen share sender");
|
||||||
|
// @ts-ignore
|
||||||
|
sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: "encrypt", sessionId: call.sessionId });
|
||||||
|
call.transformedSenders.add(sender);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to apply E2EE to screen share:", error);
|
||||||
|
throw error; // Fail securely
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("Skipping E2EE for screen share - session key not available yet or already transformed");
|
||||||
|
}
|
||||||
|
|
||||||
// Notify local screen share handler
|
// Notify local screen share handler
|
||||||
if (onLocalScreenShare) {
|
if (onLocalScreenShare) {
|
||||||
@@ -1024,6 +1121,28 @@ export function cleanupCall(userId: number): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the remote video enabled state (called when receiving signaling)
|
||||||
|
*/
|
||||||
|
export function setRemoteVideoEnabled(userId: number, enabled: boolean): void {
|
||||||
|
const call = calls.get(userId);
|
||||||
|
if (call) {
|
||||||
|
console.log(`Setting remote video enabled to ${enabled} for user ${userId}`);
|
||||||
|
call.isRemoteVideoEnabled = enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the remote screen sharing state (called when receiving signaling)
|
||||||
|
*/
|
||||||
|
export function setRemoteScreenSharing(userId: number, enabled: boolean): void {
|
||||||
|
const call = calls.get(userId);
|
||||||
|
if (call) {
|
||||||
|
console.log(`Setting remote screen sharing to ${enabled} for user ${userId}`);
|
||||||
|
call.isRemoteScreenSharing = enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function cleanup(): void {
|
export function cleanup(): void {
|
||||||
// Clean up all calls
|
// Clean up all calls
|
||||||
for (const userId of calls.keys()) {
|
for (const userId of calls.keys()) {
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ export default function useAudioCall() {
|
|||||||
const success = await WebRTC.initiateCall(userId, username);
|
const success = await WebRTC.initiateCall(userId, username);
|
||||||
|
|
||||||
if (success && sessionKey) {
|
if (success && sessionKey) {
|
||||||
|
// Set the session key for ourselves (initiator)
|
||||||
|
await WebRTC.setSessionKey(userId, sessionKey.key);
|
||||||
|
|
||||||
// Send session key hash to the receiver for visual verification
|
// Send session key hash to the receiver for visual verification
|
||||||
await WebRTC.sendCallSessionKey(userId, sessionKey.hash);
|
await WebRTC.sendCallSessionKey(userId, sessionKey.hash);
|
||||||
// Also wrap and send the actual session key for E2EE media
|
// Also wrap and send the actual session key for E2EE media
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ export default function useCall() {
|
|||||||
const success = await WebRTC.initiateCall(userId, username);
|
const success = await WebRTC.initiateCall(userId, username);
|
||||||
|
|
||||||
if (success && sessionKey) {
|
if (success && sessionKey) {
|
||||||
|
// Set the session key for ourselves (initiator)
|
||||||
|
await WebRTC.setSessionKey(userId, sessionKey.key);
|
||||||
|
|
||||||
// Send session key hash to the receiver for visual verification
|
// Send session key hash to the receiver for visual verification
|
||||||
await WebRTC.sendCallSessionKey(userId, sessionKey.hash);
|
await WebRTC.sendCallSessionKey(userId, sessionKey.hash);
|
||||||
// Also wrap and send the actual session key for E2EE media
|
// Also wrap and send the actual session key for E2EE media
|
||||||
|
|||||||
Reference in New Issue
Block a user