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";
|
import * as WebRTC from "./webrtc";
|
||||||
|
|
||||||
export interface CallState {
|
export interface CallState {
|
||||||
receiveCall: (userId: number, username: string) => void;
|
receiveCall: (userId: number, username: string) => void;
|
||||||
endCall: () => void;
|
endCall: () => void;
|
||||||
|
setCallSessionKeyHash: (sessionKeyHash: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CallSignalingHandler {
|
export class CallSignalingHandler {
|
||||||
@@ -13,7 +14,7 @@ export class CallSignalingHandler {
|
|||||||
this.getState = getState;
|
this.getState = getState;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleWebSocketMessage(message: WebSocketMessage<CallSignalingData>) {
|
handleWebSocketMessage(message: CallSignalingMessage) {
|
||||||
if (message.type !== "call_signaling") {
|
if (message.type !== "call_signaling") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -48,6 +49,9 @@ export class CallSignalingHandler {
|
|||||||
case "call_end":
|
case "call_end":
|
||||||
this.handleCallEnd(data);
|
this.handleCallEnd(data);
|
||||||
break;
|
break;
|
||||||
|
case "call_session_key":
|
||||||
|
this.handleCallSessionKey(data);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,4 +116,14 @@ export class CallSignalingHandler {
|
|||||||
// End the call
|
// End the call
|
||||||
state.endCall();
|
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 { getAuthHeaders } from "@/core/api/authApi";
|
||||||
import type { IceServersResponse } from "@/core/types";
|
import type { CallSignalingMessage, IceServersResponse } from "@/core/types";
|
||||||
import { request } from "@/core/websocket";
|
import { request } from "@/core/websocket";
|
||||||
|
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender } from "./encryption";
|
||||||
export interface CallSignalingMessage {
|
import { fetchUserPublicKey } from "@/core/api/dmApi";
|
||||||
type: "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject";
|
import { importAesGcmKey } from "@/utils/crypto/symmetric";
|
||||||
fromUserId: number;
|
import E2EEWorker from "./e2eeWorker?worker";
|
||||||
toUserId: number;
|
import { rotateCallSessionKey, createSharedSecretAndDeriveSessionKey } from "./encryption";
|
||||||
data?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WebRTCCall {
|
export interface WebRTCCall {
|
||||||
peerConnection: RTCPeerConnection;
|
peerConnection: RTCPeerConnection;
|
||||||
@@ -18,6 +16,12 @@ export interface WebRTCCall {
|
|||||||
remoteUsername: string;
|
remoteUsername: string;
|
||||||
isEnding?: boolean;
|
isEnding?: boolean;
|
||||||
isMuted?: boolean;
|
isMuted?: boolean;
|
||||||
|
// Insertable Streams E2EE
|
||||||
|
sessionKey?: Uint8Array | null;
|
||||||
|
sessionCryptoKey?: CryptoKey | null;
|
||||||
|
sessionId: string;
|
||||||
|
keyRotationTimer?: NodeJS.Timeout;
|
||||||
|
lastKeyRotation?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global state
|
// Global state
|
||||||
@@ -83,6 +87,7 @@ async function getIceServers(): Promise<RTCIceServer[]> {
|
|||||||
return defaultIceServers;
|
return defaultIceServers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function createPeerConnection(userId: number): Promise<RTCPeerConnection> {
|
async function createPeerConnection(userId: number): Promise<RTCPeerConnection> {
|
||||||
const iceServers = await getIceServers();
|
const iceServers = await getIceServers();
|
||||||
|
|
||||||
@@ -97,7 +102,10 @@ async function createPeerConnection(userId: number): Promise<RTCPeerConnection>
|
|||||||
isInitiator: false,
|
isInitiator: false,
|
||||||
remoteUserId: userId,
|
remoteUserId: userId,
|
||||||
remoteUsername: "",
|
remoteUsername: "",
|
||||||
isMuted: false
|
isMuted: false,
|
||||||
|
sessionKey: null,
|
||||||
|
sessionCryptoKey: null,
|
||||||
|
sessionId: crypto.randomUUID()
|
||||||
};
|
};
|
||||||
|
|
||||||
calls.set(userId, call);
|
calls.set(userId, call);
|
||||||
@@ -197,6 +205,17 @@ export async function initiateCall(userId: number, username: string): Promise<bo
|
|||||||
// Add tracks to peer connection
|
// Add tracks to peer connection
|
||||||
localStream.getTracks().forEach(track => call.peerConnection.addTrack(track, localStream));
|
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
|
// Send call invite
|
||||||
await sendSignalingMessage({
|
await sendSignalingMessage({
|
||||||
type: "call_invite",
|
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> {
|
export async function acceptCall(userId: number): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
let call = calls.get(userId);
|
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> {
|
export async function handleCallOffer(userId: number, offer: RTCSessionDescriptionInit): Promise<void> {
|
||||||
const call = calls.get(userId);
|
const call = calls.get(userId);
|
||||||
if (!call) {
|
if (!call) {
|
||||||
@@ -325,6 +490,9 @@ export async function handleCallOffer(userId: number, offer: RTCSessionDescripti
|
|||||||
const answer = await call.peerConnection.createAnswer();
|
const answer = await call.peerConnection.createAnswer();
|
||||||
await call.peerConnection.setLocalDescription(answer);
|
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
|
// Send answer to remote peer
|
||||||
await sendSignalingMessage({
|
await sendSignalingMessage({
|
||||||
type: "call_answer",
|
type: "call_answer",
|
||||||
@@ -346,6 +514,8 @@ export async function handleCallAnswer(userId: number, answer: RTCSessionDescrip
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await call.peerConnection.setRemoteDescription(answer);
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to handle answer:", error);
|
console.error("Failed to handle answer:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -449,6 +619,11 @@ export function getCall(userId: number): WebRTCCall | undefined {
|
|||||||
export function cleanupCall(userId: number): void {
|
export function cleanupCall(userId: number): void {
|
||||||
const call = calls.get(userId);
|
const call = calls.get(userId);
|
||||||
if (call) {
|
if (call) {
|
||||||
|
// Clear key rotation timer
|
||||||
|
if (call.keyRotationTimer) {
|
||||||
|
clearInterval(call.keyRotationTimer);
|
||||||
|
}
|
||||||
|
|
||||||
// Close peer connection
|
// Close peer connection
|
||||||
if (call.peerConnection) {
|
if (call.peerConnection) {
|
||||||
call.peerConnection.close();
|
call.peerConnection.close();
|
||||||
|
|||||||
Vendored
+6
-3
@@ -442,9 +442,12 @@ export interface CallInvite extends CallSignalingData {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CallSignalingData {
|
export type CallSignalingDataType = "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject" | "call_session_key" | "call_signaling";
|
||||||
type: "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject";
|
|
||||||
|
export interface CallSignalingMessage extends WebSocketMessage {
|
||||||
|
type: CallSignalingDataType;
|
||||||
fromUserId: number;
|
fromUserId: number;
|
||||||
toUserId: number;
|
toUserId: number;
|
||||||
data?: any;
|
sessionKeyHash?: string;
|
||||||
|
data: any;
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { API_WS_BASE_URL } from "./config";
|
import { API_WS_BASE_URL } from "./config";
|
||||||
import type { WebSocketMessage } from "./types";
|
import type { CallSignalingMessage, WebSocketMessage } from "./types";
|
||||||
import { delay } from "@/utils/utils";
|
import { delay } from "@/utils/utils";
|
||||||
import { CallSignalingHandler } from "@/core/calls/signaling";
|
import { CallSignalingHandler } from "@/core/calls/signaling";
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ async function onError() {
|
|||||||
|
|
||||||
websocket.addEventListener("message", (e) => {
|
websocket.addEventListener("message", (e) => {
|
||||||
try {
|
try {
|
||||||
const response: WebSocketMessage<any> = JSON.parse(e.data);
|
const response: CallSignalingMessage = JSON.parse(e.data);
|
||||||
|
|
||||||
// Handle call signaling messages
|
// Handle call signaling messages
|
||||||
if (callSignalingHandler && response.type === "call_signaling") {
|
if (callSignalingHandler && response.type === "call_signaling") {
|
||||||
|
|||||||
@@ -162,6 +162,24 @@
|
|||||||
color: $color-dark-on-surface-variant;
|
color: $color-dark-on-surface-variant;
|
||||||
font-weight: 500;
|
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%;
|
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 * as WebRTC from "@/core/calls/webrtc";
|
||||||
import { CallSignalingHandler } from "@/core/calls/signaling";
|
import { CallSignalingHandler } from "@/core/calls/signaling";
|
||||||
import { setCallSignalingHandler } from "@/core/websocket";
|
import { setCallSignalingHandler } from "@/core/websocket";
|
||||||
|
import { generateCallSessionKey, generateCallEmojis, createCallSessionKeyFromHash } from "@/core/calls/encryption";
|
||||||
import { createRef, useEffect } from "react";
|
import { createRef, useEffect } from "react";
|
||||||
|
|
||||||
// Global audio ref shared across all instances
|
// Global audio ref shared across all instances
|
||||||
let globalRemoteAudioRef = createRef<HTMLAudioElement>();
|
let globalRemoteAudioRef = createRef<HTMLAudioElement>();
|
||||||
|
|
||||||
export default function useAudioCall() {
|
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;
|
const remoteAudioRef = globalRemoteAudioRef;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,7 +24,8 @@ export default function useAudioCall() {
|
|||||||
const state = useAppState.getState();
|
const state = useAppState.getState();
|
||||||
state.receiveCall(userId, username);
|
state.receiveCall(userId, username);
|
||||||
},
|
},
|
||||||
endCall
|
endCall,
|
||||||
|
setCallSessionKeyHash
|
||||||
}));
|
}));
|
||||||
setCallSignalingHandler(signalingHandler);
|
setCallSignalingHandler(signalingHandler);
|
||||||
|
|
||||||
@@ -85,6 +87,14 @@ export default function useAudioCall() {
|
|||||||
};
|
};
|
||||||
}, [user.authToken, chat.call.remoteUserId, setCallStatus, endCall, startCall]);
|
}, [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> {
|
async function requestAudioPermissions(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
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
|
// Start the call in state
|
||||||
startCall(userId, username);
|
startCall(userId, username);
|
||||||
setCallStatus("calling");
|
setCallStatus("calling");
|
||||||
|
setCallEncryption(sessionKey.hash, emojis);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate call encryption:", error);
|
||||||
|
endCall();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Initiate WebRTC call
|
// Initiate WebRTC call
|
||||||
const success = await WebRTC.initiateCall(userId, username);
|
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();
|
endCall();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -162,6 +189,7 @@ export default function useAudioCall() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function handleIncomingCall(userId: number, username: string) {
|
async function handleIncomingCall(userId: number, username: string) {
|
||||||
|
// Don't generate session key here - wait for it from the caller
|
||||||
await WebRTC.handleIncomingCall(userId, username);
|
await WebRTC.handleIncomingCall(userId, username);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,6 +205,17 @@ export default function useAudioCall() {
|
|||||||
await WebRTC.handleIceCandidate(userId, candidate);
|
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 {
|
return {
|
||||||
call: chat.call,
|
call: chat.call,
|
||||||
initiateCall,
|
initiateCall,
|
||||||
@@ -188,6 +227,7 @@ export default function useAudioCall() {
|
|||||||
handleCallOffer,
|
handleCallOffer,
|
||||||
handleCallAnswer,
|
handleCallAnswer,
|
||||||
handleIceCandidate,
|
handleIceCandidate,
|
||||||
|
handleCallSessionKey,
|
||||||
remoteAudioRef
|
remoteAudioRef
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface CallState {
|
|||||||
remoteUsername: string | null;
|
remoteUsername: string | null;
|
||||||
isInitiator: boolean;
|
isInitiator: boolean;
|
||||||
isMinimized: boolean;
|
isMinimized: boolean;
|
||||||
|
sessionKeyHash: string | null;
|
||||||
|
encryptionEmojis: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatState {
|
interface ChatState {
|
||||||
@@ -75,6 +77,8 @@ interface AppState {
|
|||||||
toggleMute: () => void;
|
toggleMute: () => void;
|
||||||
toggleCallMinimize: () => void;
|
toggleCallMinimize: () => void;
|
||||||
receiveCall: (userId: number, username: string) => void;
|
receiveCall: (userId: number, username: string) => void;
|
||||||
|
setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => void;
|
||||||
|
setCallSessionKeyHash: (sessionKeyHash: string) => void;
|
||||||
|
|
||||||
// User state
|
// User state
|
||||||
user: UserState;
|
user: UserState;
|
||||||
@@ -110,7 +114,9 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
remoteUserId: null,
|
remoteUserId: null,
|
||||||
remoteUsername: null,
|
remoteUsername: null,
|
||||||
isInitiator: false,
|
isInitiator: false,
|
||||||
isMinimized: false
|
isMinimized: false,
|
||||||
|
sessionKeyHash: null,
|
||||||
|
encryptionEmojis: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addMessage: (message: Message) => set((state) => {
|
addMessage: (message: Message) => set((state) => {
|
||||||
@@ -404,7 +410,9 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
remoteUserId: userId,
|
remoteUserId: userId,
|
||||||
remoteUsername: username,
|
remoteUsername: username,
|
||||||
isInitiator: true,
|
isInitiator: true,
|
||||||
isMinimized: false
|
isMinimized: false,
|
||||||
|
sessionKeyHash: null,
|
||||||
|
encryptionEmojis: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
@@ -420,7 +428,9 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
remoteUserId: null,
|
remoteUserId: null,
|
||||||
remoteUsername: null,
|
remoteUsername: null,
|
||||||
isInitiator: false,
|
isInitiator: false,
|
||||||
isMinimized: false
|
isMinimized: false,
|
||||||
|
sessionKeyHash: null,
|
||||||
|
encryptionEmojis: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
@@ -467,7 +477,30 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
remoteUserId: userId,
|
remoteUserId: userId,
|
||||||
remoteUsername: username,
|
remoteUsername: username,
|
||||||
isInitiator: false,
|
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