From d4b1e261d79530733703c0d289898a45da59a201 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 19 Nov 2025 16:01:25 +0300 Subject: [PATCH] Implement Signal Protocol --- backend/models.py | 9 + backend/routes/account.py | 89 ++++++ frontend/src/core/api/account/index.ts | 1 + frontend/src/core/api/crypto.ts | 30 ++ frontend/src/core/api/dm.ts | 190 +---------- frontend/src/core/api/dmApi.ts | 89 +++--- frontend/src/core/calls/encryption.ts | 217 ++++--------- frontend/src/core/calls/signaling.ts | 12 +- frontend/src/core/calls/webrtc.ts | 43 +-- frontend/src/core/types.d.ts | 8 +- frontend/src/pages/auth/LoginForm.tsx | 10 + frontend/src/pages/chat/hooks/useDM.ts | 21 +- frontend/src/pages/chat/state/chat.ts | 226 ++++++++++++++ frontend/src/pages/chat/state/chatState.ts | 226 ++++++++++++++ frontend/src/pages/chat/state/types.ts | 73 +++++ .../src/pages/chat/ui/right/panels/DMPanel.ts | 46 +-- frontend/src/utils/crypto/signalProtocol.ts | 210 +++++++++++++ frontend/src/utils/crypto/signalStorage.ts | 295 ++++++++++++++++++ package.json | 1 + 19 files changed, 1352 insertions(+), 444 deletions(-) create mode 100644 frontend/src/pages/chat/state/chat.ts create mode 100644 frontend/src/pages/chat/state/chatState.ts create mode 100644 frontend/src/pages/chat/state/types.ts create mode 100644 frontend/src/utils/crypto/signalProtocol.ts create mode 100644 frontend/src/utils/crypto/signalStorage.ts diff --git a/backend/models.py b/backend/models.py index 5f4c71e..065915c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -71,6 +71,15 @@ class CryptoBackup(Base): blob_json = Column(Text, nullable=False) +class SignalPreKeyBundle(Base): + __tablename__ = "signal_prekey_bundle" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True) + bundle_json = Column(Text, nullable=False) # JSON string of PreKeyBundleData + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + class DMEnvelope(Base): __tablename__ = "dm_envelope" diff --git a/backend/routes/account.py b/backend/routes/account.py index 445930e..b592b88 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -479,6 +479,95 @@ def get_public_key_of(request: Request, user_id: int, current_user: User = Depen return {"publicKey": row.public_key_b64 if row else None} +@router.post("/crypto/signal/prekey-bundle") +@rate_limit_per_ip("10/minute") +def upload_prekey_bundle( + request: Request, + payload: dict, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Upload Signal Protocol prekey bundle for the current user""" + from models import SignalPreKeyBundle + import json + + bundle = payload.get("bundle") + if not bundle: + raise HTTPException(status_code=400, detail="bundle required") + + # Validate bundle structure + if not isinstance(bundle, dict): + raise HTTPException(status_code=400, detail="bundle must be a JSON object") + + # Validate required fields + required_fields = ["registrationId", "identityKey", "signedPreKey"] + for field in required_fields: + if field not in bundle: + raise HTTPException(status_code=400, detail=f"Missing required field: {field}") + + if not isinstance(bundle["signedPreKey"], dict) or "keyId" not in bundle["signedPreKey"]: + raise HTTPException(status_code=400, detail="Invalid signedPreKey format") + + # Store as JSON string + bundle_json = json.dumps(bundle) + if len(bundle_json) > 50000: # 50KB limit + raise HTTPException(status_code=400, detail="Bundle too large") + + row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first() + if row: + row.bundle_json = bundle_json + row.updated_at = datetime.now() + else: + row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json) + db.add(row) + db.commit() + + return {"status": "ok"} + + +@router.get("/crypto/signal/prekey-bundle") +def get_prekey_bundle( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get Signal Protocol prekey bundle for the current user""" + from models import SignalPreKeyBundle + import json + + row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first() + if not row: + raise HTTPException(status_code=404, detail="Prekey bundle not found") + + try: + bundle = json.loads(row.bundle_json) + return {"bundle": bundle} + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="Invalid bundle data") + + +@router.get("/crypto/signal/prekey-bundle/of/{user_id}") +@rate_limit_per_ip("100/minute") +def get_prekey_bundle_of( + request: Request, + user_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get Signal Protocol prekey bundle for another user""" + from models import SignalPreKeyBundle + import json + + row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == user_id).first() + if not row: + raise HTTPException(status_code=404, detail="Prekey bundle not found") + + try: + bundle = json.loads(row.bundle_json) + return {"bundle": bundle} + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="Invalid bundle data") + + @router.get("/users/search") @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): diff --git a/frontend/src/core/api/account/index.ts b/frontend/src/core/api/account/index.ts index af2318b..d69468a 100644 --- a/frontend/src/core/api/account/index.ts +++ b/frontend/src/core/api/account/index.ts @@ -127,6 +127,7 @@ export async function deriveAuthSecret(username: string, password: string): Prom return b64(derived); } + export async function ensureKeysOnLogin(password: string, token: string): Promise { // Try to restore from backup const blobJson = await fetchBackupBlob(token); diff --git a/frontend/src/core/api/crypto.ts b/frontend/src/core/api/crypto.ts index ddb668f..e64c7e7 100644 --- a/frontend/src/core/api/crypto.ts +++ b/frontend/src/core/api/crypto.ts @@ -2,6 +2,7 @@ import { API_BASE_URL } from "@/core/config"; import { getAuthHeaders } from "./account"; import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types"; import { b64, ub64 } from "@/utils/utils"; +import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol"; /** * Fetches the current user's public key @@ -74,3 +75,32 @@ export async function uploadBackupBlob(blobJson: string, token: string): Promise if (!res.ok) throw new Error("Failed to upload backup blob"); } +/** + * Uploads Signal Protocol prekey bundle for the current user + */ +export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise { + const payload = { bundle }; + + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle`, { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error("Failed to upload prekey bundle"); +} + +/** + * Fetches Signal Protocol prekey bundle for another user + */ +export async function fetchPreKeyBundle(userId: number, token: string): Promise { + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, { + method: "GET", + headers + }); + if (!res.ok) return null; + const data = await res.json(); + return data.bundle || null; +} + diff --git a/frontend/src/core/api/dm.ts b/frontend/src/core/api/dm.ts index b0cc194..0486c64 100644 --- a/frontend/src/core/api/dm.ts +++ b/frontend/src/core/api/dm.ts @@ -1,178 +1,16 @@ -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "./account"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { randomBytes } from "@/utils/crypto/kdf"; -import { getCurrentKeys } from "./account"; -import { request } from "@/core/websocket"; -import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; -import { b64, ub64 } from "@/utils/utils"; -import { fetchUserPublicKey } from "./crypto"; -import { fetchUsers, searchUsers } from "./users"; +// Re-export from dmApi.ts which has Signal Protocol support +export { + decryptDm, + fetchDMHistory, + sendDMViaWebSocket, + sendDmWithFiles, + editDmEnvelope, + deleteDmEnvelope, + fetchDMConversations, + fetchUsers, + searchUsers, + fetchUserPublicKey +} from "./dmApi"; -export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); - - // Obtain the key - const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk)); - - // Decrypt - const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext)); - return new TextDecoder().decode(msg); -} - -export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise { - const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, { - headers: getAuthHeaders(token, true) - }); - if (!response.ok) return []; - const data = await response.json(); - return data.messages || []; -} - -// Re-export user functions for convenience -export { fetchUsers, searchUsers, fetchUserPublicKey }; - -export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); - - // Encryption key - const mk = randomBytes(32); - const wkSalt = randomBytes(16); - const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - - // Encrypt the message - const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext)); - const wrap = await aesGcmEncrypt(wk, mk); - - const payload: SendDMRequest = { - recipientId: recipientId, - iv: b64(encMsg.iv), - ciphertext: b64(encMsg.ciphertext), - salt: b64(wkSalt), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext) - }; - if (replyToId) payload.replyToId = replyToId; - - await request({ - type: "dmSend", - credentials: { - scheme: "Bearer", - credentials: authToken - }, - data: payload - }); -} - -export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); - - const mk = randomBytes(32); - const wkSalt = randomBytes(16); - const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - - const wrap = await aesGcmEncrypt(wk, mk); - - const form = new FormData(); - const names: string[] = []; - function sliceBuffer(u8: Uint8Array): ArrayBuffer { - return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength); - } - - for (const f of files) { - // Encrypt file with same mk - const data = new Uint8Array(await f.arrayBuffer()); - const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data); - const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" }); - const serverName = f.name; // server uses provided name - names.push(serverName); - form.append("files", new File([blob], serverName)); - } - form.append("fileNames", JSON.stringify(names)); - - // Merge files metadata into plaintext JSON and encrypt - let obj: DmEncryptedJSON; - try { - obj = JSON.parse(plaintextJson); - } catch { - obj = { type: "text", data: { content: String(plaintextJson) } }; - } - - const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj))); - form.append("dm_payload", JSON.stringify({ - recipientId: recipientId, - iv: b64(encMsg.iv), - ciphertext: b64(encMsg.ciphertext), - salt: b64(wkSalt), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext) - } satisfies BaseDmEnvelope)); - - await fetch(`${API_BASE_URL}/dm/send`, { - method: "POST", - headers: getAuthHeaders(token, false), - body: form - }); -} - -export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); - - // We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap - const mk = randomBytes(32); - const wkSalt = randomBytes(16); - const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson)); - const wrap = await aesGcmEncrypt(wk, mk); - - await request({ - type: "dmEdit", - credentials: { scheme: "Bearer", credentials: authToken }, - data: { - id, - iv: b64(encMsg.iv), - ciphertext: b64(encMsg.ciphertext), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext), - salt: b64(wkSalt) - } - } as DMEditRequest); -} - -export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise { - await request({ - type: "dmDelete", - credentials: { scheme: "Bearer", credentials: authToken }, - data: { id, recipientId } - }); -} - -export interface DMConversationResponse { - user: User; - lastMessage: DmEnvelope; - unreadCount: number; -} - -export async function fetchDMConversations(token: string): Promise { - const res = await fetch(`${API_BASE_URL}/dm/conversations`, { - headers: getAuthHeaders(token, true) - }); - if (!res.ok) return []; - const data = await res.json(); - return data.conversations || []; -} +export type { DMConversationResponse } from "./dmApi"; diff --git a/frontend/src/core/api/dmApi.ts b/frontend/src/core/api/dmApi.ts index b0cc194..b66e55c 100644 --- a/frontend/src/core/api/dmApi.ts +++ b/frontend/src/core/api/dmApi.ts @@ -1,28 +1,33 @@ import { API_BASE_URL } from "@/core/config"; import { getAuthHeaders } from "./account"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; +import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric"; import { randomBytes } from "@/utils/crypto/kdf"; import { getCurrentKeys } from "./account"; import { request } from "@/core/websocket"; import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; import { b64, ub64 } from "@/utils/utils"; -import { fetchUserPublicKey } from "./crypto"; +import { fetchUserPublicKey, fetchPreKeyBundle } from "./crypto"; import { fetchUsers, searchUsers } from "./users"; +import { SignalProtocolService } from "@/utils/crypto/signalProtocol"; +import { useUserStore } from "@/state/user"; +import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); +export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise { + const user = useUserStore.getState().user.currentUser; + if (!user?.id) { + throw new Error("User not authenticated"); + } - // Obtain the key - const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk)); - - // Decrypt - const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext)); - return new TextDecoder().decode(msg); + const signalService = new SignalProtocolService(user.id.toString()); + + // Parse Signal Protocol message + const signalCiphertext = JSON.parse(envelope.ciphertext); + if (!signalCiphertext.type || !signalCiphertext.body) { + throw new Error("Invalid Signal Protocol message format"); + } + + const plaintext = await signalService.decryptMessage(senderId, signalCiphertext); + return plaintext; } export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise { @@ -34,31 +39,36 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe return data.messages || []; } -// Re-export user functions for convenience -export { fetchUsers, searchUsers, fetchUserPublicKey }; -export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); - - // Encryption key - const mk = randomBytes(32); - const wkSalt = randomBytes(16); - const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - - // Encrypt the message - const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext)); - const wrap = await aesGcmEncrypt(wk, mk); +export async function sendDMViaWebSocket(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise { + const user = useUserStore.getState().user.currentUser; + if (!user?.id) { + throw new Error("User not authenticated"); + } + const signalService = new SignalProtocolService(user.id.toString()); + + // Check if we have a session, if not, fetch prekey bundle and establish one + const hasSession = await signalService.hasSession(recipientId); + if (!hasSession) { + // Fetch prekey bundle from server + const bundle = await fetchPreKeyBundle(recipientId, authToken); + if (!bundle) { + throw new Error("No Signal Protocol prekey bundle available for recipient"); + } + await signalService.processPreKeyBundle(recipientId, bundle); + } + + // Encrypt with Signal Protocol + const ciphertext = await signalService.encryptMessage(recipientId, plaintext); + const payload: SendDMRequest = { recipientId: recipientId, - iv: b64(encMsg.iv), - ciphertext: b64(encMsg.ciphertext), - salt: b64(wkSalt), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext) + iv: "", // Not used for Signal Protocol + ciphertext: JSON.stringify(ciphertext), // Store Signal Protocol message as JSON + salt: "", // Not used for Signal Protocol + iv2: "", // Not used for Signal Protocol + wrappedMk: "" // Not used for Signal Protocol }; if (replyToId) payload.replyToId = replyToId; @@ -78,7 +88,7 @@ export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64 const mk = randomBytes(32); const wkSalt = randomBytes(16); - const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); const wk = await importAesGcmKey(wkRaw); @@ -133,7 +143,7 @@ export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, // We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap const mk = randomBytes(32); const wkSalt = randomBytes(16); - const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); const wk = await importAesGcmKey(wkRaw); const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson)); @@ -167,6 +177,9 @@ export interface DMConversationResponse { unreadCount: number; } +// Re-export for convenience +export { fetchUsers, searchUsers, fetchUserPublicKey }; + export async function fetchDMConversations(token: string): Promise { const res = await fetch(`${API_BASE_URL}/dm/conversations`, { headers: getAuthHeaders(token, true) diff --git a/frontend/src/core/calls/encryption.ts b/frontend/src/core/calls/encryption.ts index 9ebc700..c62c3b6 100644 --- a/frontend/src/core/calls/encryption.ts +++ b/frontend/src/core/calls/encryption.ts @@ -1,29 +1,15 @@ -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 api from "@/core/api"; -import type { WrappedSessionKeyPayload } from "@/core/types"; +import { SignalProtocolService } from "@/utils/crypto/signalProtocol"; +import { useUserStore } from "@/state/user"; +import { fetchPreKeyBundle } from "@/core/api/crypto"; +import { getAuthToken } from "@/core/api/account"; 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; -} - /** * Generates a new call session key for end-to-end encryption * @returns Promise that resolves to a session key with its hash for display @@ -60,97 +46,6 @@ export async function rotateCallSessionKey(): Promise { }; } -/** - * Create session key from hash (for backward compatibility) - * @deprecated Use deriveCallSessionKeyFromSharedSecret instead - */ -export async function createCallSessionKeyFromHash(hash: string): Promise { - // For backward compatibility, generate a deterministic key from the hash - const hashBytes = ub64(hash); - const sessionKey = new Uint8Array(32); - - // Repeat the hash bytes to fill 32 bytes - for (let i = 0; i < 32; i++) { - sessionKey[i] = hashBytes[i % hashBytes.length]; - } - - return { - key: sessionKey, - hash - }; -} - -/** - * Derive session key from ECDH shared secret and session key hash - * This creates a deterministic but cryptographically secure key - */ -export async function deriveCallSessionKeyFromSharedSecret( - sharedSecret: Uint8Array, - sessionKeyHash: string, - isInitiator: boolean -): Promise { - // Use HKDF to derive the session key from the shared secret - // Include the session key hash and role to ensure uniqueness - const info = new TextEncoder().encode(`call-session-${sessionKeyHash}-${isInitiator ? 'initiator' : 'receiver'}`); - const salt = new Uint8Array(32); // Zero salt for deterministic derivation - - // Import the shared secret as a raw key for HKDF - const sharedKey = await crypto.subtle.importKey( - 'raw', - sharedSecret.buffer as ArrayBuffer, - { name: 'HKDF' }, - false, - ['deriveKey'] - ); - - // Derive the session key using HKDF - const sessionKey = await crypto.subtle.deriveKey( - { - name: 'HKDF', - hash: 'SHA-256', - salt: salt, - info: info - }, - sharedKey, - { name: 'AES-GCM', length: 256 }, - true, // Make the key extractable so we can export it - ['encrypt', 'decrypt'] - ); - - // Export the raw key material - const sessionKeyMaterial = await crypto.subtle.exportKey('raw', sessionKey); - - return { - key: new Uint8Array(sessionKeyMaterial), - hash: sessionKeyHash - }; -} - -/** - * Encrypt a call signaling message with the session key - */ -export async function encryptCallMessage(message: Record, sessionKey: Uint8Array): Promise { - const messageKey = await importAesGcmKey(sessionKey); - const encrypted = await aesGcmEncrypt(messageKey, new TextEncoder().encode(JSON.stringify(message))); - - return { - iv: b64(encrypted.iv), - ciphertext: b64(encrypted.ciphertext), - salt: "", // Not used for message encryption, only for key wrapping - iv2: "", - wrappedSessionKey: "" - }; -} - -/** - * Decrypt a call signaling message - */ -export async function decryptCallMessage(encryptedMessage: EncryptedCallMessage, sessionKey: Uint8Array): Promise> { - const messageKey = await importAesGcmKey(sessionKey); - const decrypted = await aesGcmDecrypt(messageKey, ub64(encryptedMessage.iv), ub64(encryptedMessage.ciphertext)); - return JSON.parse(new TextDecoder().decode(decrypted)); -} - /** * Generate 4 emojis representing the call session key */ @@ -176,63 +71,65 @@ export function generateCallEmojis(sessionKeyHash: string): string[] { return emojis; } -// HKDF info for CALL key wrapping (distinct from DM's info) -const CALL_INFO = new Uint8Array([2]); - /** - * Wraps a call session key for a specific recipient using ECDH key exchange - * @param recipientPublicKeyB64 - The recipient's public key in base64 format - * @param sessionKey - The session key to wrap - * @returns Promise that resolves to the wrapped session key payload + * Encrypts a call session key using Signal Protocol + * @param recipientId - The recipient's user ID + * @param sessionKey - The session key to encrypt + * @returns Promise that resolves to encrypted session key data */ -export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise { - const keys = api.user.auth.getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); +export async function encryptCallSessionKey(recipientId: number, sessionKey: Uint8Array): Promise<{ type: number; body: string }> { + const user = useUserStore.getState().user.currentUser; + if (!user?.id) { + throw new Error("User not authenticated"); + } - 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) - }; + const signalService = new SignalProtocolService(user.id.toString()); + + // Ensure we have a session with the recipient + const hasSession = await signalService.hasSession(recipientId); + if (!hasSession) { + // Fetch prekey bundle and establish session + const token = getAuthToken(); + if (!token) { + throw new Error("No auth token"); + } + + const bundle = await fetchPreKeyBundle(recipientId, token); + if (!bundle) { + throw new Error("No prekey bundle available for recipient"); + } + + await signalService.processPreKeyBundle(recipientId, bundle); + } + + // Encrypt the session key using Signal Protocol + const sessionKeyString = b64(sessionKey); + const encrypted = await signalService.encryptMessage(recipientId, sessionKeyString); + + return encrypted; } /** - * Create a shared secret and derive session key for the receiver + * Decrypts a call session key using Signal Protocol + * @param senderId - The sender's user ID + * @param encryptedKey - The encrypted session key data + * @returns Promise that resolves to the decrypted session key */ -export async function createSharedSecretAndDeriveSessionKey( - senderPublicKeyB64: string, - sessionKeyHash: string, - isInitiator: boolean -): Promise { - const keys = api.user.auth.getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); +export async function decryptCallSessionKey(senderId: number, encryptedKey: { type: number; body: string }): Promise { + const user = useUserStore.getState().user.currentUser; + if (!user?.id) { + throw new Error("User not authenticated"); + } - // 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); -} - -/** - * Unwraps a call session key received from a sender using ECDH key exchange - * @param senderPublicKeyB64 - The sender's public key in base64 format - * @param payload - The wrapped session key payload - * @returns Promise that resolves to the unwrapped session key - */ -export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise { - const keys = api.user.auth.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); + const signalService = new SignalProtocolService(user.id.toString()); + + // Decrypt using Signal Protocol + const decryptedString = await signalService.decryptMessage(senderId, encryptedKey); + + // Convert back to Uint8Array + const sessionKey = new Uint8Array( + atob(decryptedString).split("").map(c => c.charCodeAt(0)) + ); + + return sessionKey; } diff --git a/frontend/src/core/calls/signaling.ts b/frontend/src/core/calls/signaling.ts index 36f738c..55869f5 100644 --- a/frontend/src/core/calls/signaling.ts +++ b/frontend/src/core/calls/signaling.ts @@ -1,4 +1,4 @@ -import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData } from "@/core/types"; +import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData, CallSessionKeyData } from "@/core/types"; import * as WebRTC from "./webrtc"; export interface CallState { @@ -133,12 +133,16 @@ export class CallSignalingHandler { private handleCallSessionKey(message: CallSignalingMessage) { const state = this.getState(); - const { sessionKeyHash, data } = message; + const { sessionKeyHash } = message; + const data = message.data as CallSessionKeyData; + if (sessionKeyHash) { state.setCallSessionKeyHash(sessionKeyHash); } - if (data && 'wrappedSessionKey' in data && data.wrappedSessionKey && message.fromUserId) { - WebRTC.receiveWrappedSessionKey(message.fromUserId, data.wrappedSessionKey, sessionKeyHash); + + // Check if data is CallSessionKeyData and has encryptedSessionKey + if (data && data.encryptedSessionKey && message.fromUserId) { + WebRTC.receiveWrappedSessionKey(message.fromUserId, data.encryptedSessionKey); } } diff --git a/frontend/src/core/calls/webrtc.ts b/frontend/src/core/calls/webrtc.ts index dbacf14..a1f62a8 100644 --- a/frontend/src/core/calls/webrtc.ts +++ b/frontend/src/core/calls/webrtc.ts @@ -1,7 +1,7 @@ import api from "@/core/api"; -import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types"; +import type { CallSignalingMessage } from "@/core/types"; import { request } from "@/core/websocket"; -import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; +import { encryptCallSessionKey, decryptCallSessionKey, rotateCallSessionKey } from "./encryption"; import { importAesGcmKey } from "@/utils/crypto/symmetric"; import E2EEWorker from "./e2eeWorker?worker"; import { delay } from "@/utils/utils"; @@ -855,21 +855,18 @@ export async function sendCallSessionKey(userId: number, sessionKeyHash: string) export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise { try { - const recipientPublicKey = await api.chats.dm.fetchUserPublicKey(userId, api.user.auth.getAuthToken()!); - if (!recipientPublicKey) { - console.warn("No recipient public key for", userId); - return; - } - const wrapped = await wrapCallSessionKeyForRecipient(recipientPublicKey, sessionKey); + // Encrypt session key using Signal Protocol + const encrypted = await encryptCallSessionKey(userId, sessionKey); + await sendSignalingMessage({ type: "call_session_key", fromUserId: 0, toUserId: userId, sessionKeyHash, - data: { wrappedSessionKey: wrapped } + data: { encryptedSessionKey: encrypted } }); } catch (e) { - console.error("Failed to send wrapped session key:", e); + console.error("Failed to send encrypted session key:", e); } } @@ -886,31 +883,21 @@ export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promi export async function receiveWrappedSessionKey( fromUserId: number, - wrappedPayload: WrappedSessionKeyPayload, - sessionKeyHash?: string + encryptedKey: { type: number; body: string } ): Promise { try { - const senderPublicKey = await api.chats.dm.fetchUserPublicKey(fromUserId, api.user.auth.getAuthToken()!); - if (!senderPublicKey) { - console.error("Failed to get sender public key"); - return; - } - if (!wrappedPayload || !sessionKeyHash) { - console.error("Missing wrapped payload or session key hash"); + if (!encryptedKey) { + console.error("Missing encrypted session key"); return; } - // Unwrap the session key from the encrypted payload - const unwrappedSessionKey = await unwrapCallSessionKeyFromSender(senderPublicKey, { - salt: wrappedPayload.salt, - iv2: wrappedPayload.iv2, - wrapped: wrappedPayload.wrapped - }); + // Decrypt the session key using Signal Protocol + const sessionKey = await decryptCallSessionKey(fromUserId, encryptedKey); - // Use the unwrapped session key directly (both sides should have the same key) - await setSessionKey(fromUserId, unwrappedSessionKey); + // Use the decrypted session key for media encryption + await setSessionKey(fromUserId, sessionKey); } catch (e) { - console.error("Failed to unwrap session key:", e); + console.error("Failed to decrypt session key:", e); } } diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 864f04e..cb04c45 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -505,7 +505,7 @@ export interface CallEndData { } export interface CallSessionKeyData { - wrappedSessionKey?: WrappedSessionKeyPayload; + encryptedSessionKey: { type: number; body: string }; } export interface CallVideoToggleData { @@ -526,12 +526,6 @@ export interface CallScreenShareToggleMessageData { data: CallScreenShareToggleData; } -export interface WrappedSessionKeyPayload { - salt: string; - iv2: string; - wrapped: string; -} - export interface CallVideoToggleMessage extends CallSignalingMessage { type: "call_video_toggle"; data: CallVideoToggleData; diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index 17f96c1..64cf407 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -92,6 +92,16 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { try { await api.user.auth.ensureKeysOnLogin(password, data.token); + + // Initialize Signal Protocol after keys are set up + if (data.user?.id) { + const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol"); + const { uploadPreKeyBundle } = await import("@/core/api/crypto"); + const signalService = new SignalProtocolService(data.user.id.toString()); + await signalService.initialize(); + const bundle = await signalService.getPreKeyBundle(); + await uploadPreKeyBundle(bundle, data.token); + } } catch (e) { console.error("Key setup failed:", e); } diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index 579760c..c8a1690 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useUserStore } from "@/state/user"; import { useChatStore } from "@/state/chat"; import api from "@/core/api"; +import { decryptDm, sendDMViaWebSocket } from "@/core/api/dm"; import type { ConversationResponse } from "@/core/api/chats/dm"; import type { User, Message, DmEncryptedJSON } from "@/core/types"; import { websocket } from "@/core/websocket"; @@ -64,7 +65,7 @@ export function useDM() { let lastPlaintext: string | null = null; try { - lastPlaintext = (JSON.parse(await api.chats.dm.decrypt(lastMessage, publicKey)) as DmEncryptedJSON).data.content; + lastPlaintext = (JSON.parse(await decryptDm(lastMessage, lastMessage.senderId)) as DmEncryptedJSON).data.content; } catch (error) { console.error("Failed to decrypt last message:", error); } @@ -118,7 +119,7 @@ export function useDM() { const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { // Decrypt the last message - const decryptedJson = await api.chats.dm.decrypt(conv.lastMessage, publicKey!); + const decryptedJson = await decryptDm(conv.lastMessage, conv.lastMessage.senderId); const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!); } @@ -152,7 +153,7 @@ export function useDM() { }, [user.authToken]); // Load DM history for active conversation - const loadDMHistory = useCallback(async (userId: number, publicKey: string) => { + const loadDMHistory = useCallback(async (userId: number) => { if (!user.authToken || isLoadingHistory) return; setIsLoadingHistory(true); @@ -163,7 +164,7 @@ export function useDM() { for (const env of messages) { try { - const text = await api.chats.dm.decrypt(env, publicKey); + const text = await decryptDm(env, env.senderId); const isAuthor = env.senderId !== userId; const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User"; @@ -204,11 +205,11 @@ export function useDM() { }, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]); // Send DM message - const sendDMMessage = useCallback(async (recipientId: number, publicKey: string, content: string) => { + const sendDMMessage = useCallback(async (recipientId: number, content: string) => { if (!user.authToken) return; try { - await api.chats.dm.send(recipientId, publicKey, content, user.authToken); + await sendDMViaWebSocket(recipientId, content, user.authToken); } catch (error) { console.error("Failed to send DM:", error); } @@ -234,7 +235,7 @@ export function useDM() { }); // Load conversation history - await loadDMHistory(dmUser.id, publicKey); + await loadDMHistory(dmUser.id); } catch (error) { console.error("Failed to start DM conversation:", error); } @@ -267,7 +268,7 @@ export function useDM() { const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { // Decrypt the last message - const decryptedJson = await api.chats.dm.decrypt(userConversation.lastMessage, publicKey!); + const decryptedJson = await decryptDm(userConversation.lastMessage, userConversation.lastMessage.senderId); const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser?.id!); } @@ -318,7 +319,7 @@ export function useDM() { try { const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { - const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey); + const decryptedJson = await decryptDm(envelope, senderId); const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; const messageContent = decryptedData.data.content; const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); @@ -348,7 +349,7 @@ export function useDM() { try { const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { - const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey); + const decryptedJson = await decryptDm(envelope, senderId); const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; const messageContent = decryptedData.data.content; const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); diff --git a/frontend/src/pages/chat/state/chat.ts b/frontend/src/pages/chat/state/chat.ts new file mode 100644 index 0000000..a127fbe --- /dev/null +++ b/frontend/src/pages/chat/state/chat.ts @@ -0,0 +1,226 @@ +import type { StateCreator } from "zustand"; +import type { Message, User } from "@/core/types"; +import { MessagePanel } from "../ui/right/panels/MessagePanel"; +import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel"; +import { DMPanel, type DMPanelData } from "../ui/right/panels/DMPanel"; +import type { ChatState, ChatTabs, ActiveDM } from "./types"; +import { useUserStore } from "@/state/user"; + +export interface ChatStateSlice { + chat: ChatState; + addMessage: (message: Message) => void; + updateMessage: (messageId: number, updatedMessage: Partial) => void; + removeMessage: (messageId: number) => void; + setCurrentChat: (chat: string) => void; + setActiveTab: (tab: ChatTabs) => void; + setDmUsers: (users: User[]) => void; + setActiveDm: (dm: ActiveDM | null) => void; + clearMessages: () => void; + setActivePanel: (panel: MessagePanel | null) => void; + setPendingPanel: (panel: MessagePanel | null) => void; + applyPendingPanel: () => void; + switchToPublicChat: (chatName: string) => Promise; + switchToDM: (dmData: DMPanelData) => Promise; +} + +export const createChatState: StateCreator< + ChatStateSlice & { user: { authToken: string | null } }, + [], + [], + ChatStateSlice +> = (set, get) => ({ + chat: { + messages: [], + currentChat: "Общий чат", + activeTab: "chats", + dmUsers: [], + activeDm: null, + isSwitching: false, + setIsSwitching: (value: boolean) => set((state) => ({ + chat: { + ...state.chat, + isSwitching: value + } + })), + activePanel: null, + publicChatPanel: null, + dmPanel: null, + pendingPanel: null, + profileDialog: null, + call: { + isActive: false, + status: "ended", + startTime: null, + isMuted: false, + remoteUserId: null, + remoteUsername: null, + isInitiator: false, + isMinimized: false, + sessionKeyHash: null, + encryptionEmojis: [], + isVideoEnabled: false, + isRemoteVideoEnabled: false, + isSharingScreen: false, + isRemoteScreenSharing: false + }, + onlineStatuses: new Map(), + typingUsers: new Map(), + dmTypingUsers: new Map() + }, + addMessage: (message: Message) => set((state) => { + const messageExists = state.chat.messages.some(msg => msg.id === message.id); + if (messageExists) { + return state; + } + + return { + chat: { + ...state.chat, + messages: [...state.chat.messages, message] + } + }; + }), + updateMessage: (messageId: number, updatedMessage: Partial) => set((state) => ({ + chat: { + ...state.chat, + messages: state.chat.messages.map(msg => + msg.id === messageId ? { ...msg, ...updatedMessage } : msg + ) + } + })), + removeMessage: (messageId: number) => set((state) => ({ + chat: { + ...state.chat, + messages: state.chat.messages.filter(msg => msg.id !== messageId) + } + })), + clearMessages: () => set((state) => ({ + chat: { + ...state.chat, + messages: [] + } + })), + setCurrentChat: (chat: string) => set((state) => ({ + chat: { + ...state.chat, + currentChat: chat + } + })), + setActiveTab: (tab: ChatTabs) => set((state) => ({ + chat: { + ...state.chat, + activeTab: tab + } + })), + setDmUsers: (users: User[]) => set((state) => ({ + chat: { + ...state.chat, + dmUsers: users + } + })), + setActiveDm: (dm: ActiveDM | null) => set((state) => ({ + chat: { + ...state.chat, + activeDm: dm + } + })), + setActivePanel: (panel: MessagePanel | null) => { + const state = get(); + if (state.chat.activePanel && state.chat.activePanel !== panel) { + state.chat.activePanel.deactivate(); + } + return set((state) => ({ + chat: { + ...state.chat, + activePanel: panel + } + })); + }, + setPendingPanel: (panel: MessagePanel | null) => set((state) => ({ + chat: { + ...state.chat, + pendingPanel: panel + } + })), + applyPendingPanel: () => { + const state = get(); + if (state.chat.activePanel) { + state.chat.activePanel.deactivate(); + } + return set((state) => ({ + chat: { + ...state.chat, + activePanel: state.chat.pendingPanel || state.chat.activePanel, + publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel) + ? (state.chat.pendingPanel as PublicChatPanel) + : state.chat.publicChatPanel, + dmPanel: (state.chat.pendingPanel instanceof DMPanel) + ? (state.chat.pendingPanel as DMPanel) + : state.chat.dmPanel, + currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat, + pendingPanel: null + } + })); + }, + switchToPublicChat: async (chatName: string) => { + const { chat } = get(); + const { user } = useUserStore(); + + if (!user.authToken) return; + + chat.setIsSwitching(true); + + let publicChatPanel = chat.publicChatPanel; + if (!publicChatPanel) { + publicChatPanel = new PublicChatPanel(chatName, user); + } else { + publicChatPanel.setChatName(chatName); + publicChatPanel.setAuthToken(user.authToken); + publicChatPanel.clearMessages(); + } + + await publicChatPanel.activate(); + + set((state) => ({ + chat: { + ...state.chat, + pendingPanel: publicChatPanel, + activeTab: "chats" + } + })); + }, + switchToDM: async (dmData: DMPanelData) => { + const { chat } = get(); + const { user } = useUserStore(); + + if (!user.authToken) return; + + chat.setIsSwitching(true); + + let dmPanel = chat.dmPanel; + if (!dmPanel) { + dmPanel = new DMPanel(user); + } else { + dmPanel.setAuthToken(user.authToken); + dmPanel.clearMessages(); + } + + dmPanel.setDMData(dmData); + + await dmPanel.activate(); + + set((state) => ({ + chat: { + ...state.chat, + pendingPanel: dmPanel, + activeDm: { + userId: dmData.userId, + username: dmData.username, + publicKey: dmData.publicKey + }, + activeTab: "chats" + } + })); + } +}); + diff --git a/frontend/src/pages/chat/state/chatState.ts b/frontend/src/pages/chat/state/chatState.ts new file mode 100644 index 0000000..a127fbe --- /dev/null +++ b/frontend/src/pages/chat/state/chatState.ts @@ -0,0 +1,226 @@ +import type { StateCreator } from "zustand"; +import type { Message, User } from "@/core/types"; +import { MessagePanel } from "../ui/right/panels/MessagePanel"; +import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel"; +import { DMPanel, type DMPanelData } from "../ui/right/panels/DMPanel"; +import type { ChatState, ChatTabs, ActiveDM } from "./types"; +import { useUserStore } from "@/state/user"; + +export interface ChatStateSlice { + chat: ChatState; + addMessage: (message: Message) => void; + updateMessage: (messageId: number, updatedMessage: Partial) => void; + removeMessage: (messageId: number) => void; + setCurrentChat: (chat: string) => void; + setActiveTab: (tab: ChatTabs) => void; + setDmUsers: (users: User[]) => void; + setActiveDm: (dm: ActiveDM | null) => void; + clearMessages: () => void; + setActivePanel: (panel: MessagePanel | null) => void; + setPendingPanel: (panel: MessagePanel | null) => void; + applyPendingPanel: () => void; + switchToPublicChat: (chatName: string) => Promise; + switchToDM: (dmData: DMPanelData) => Promise; +} + +export const createChatState: StateCreator< + ChatStateSlice & { user: { authToken: string | null } }, + [], + [], + ChatStateSlice +> = (set, get) => ({ + chat: { + messages: [], + currentChat: "Общий чат", + activeTab: "chats", + dmUsers: [], + activeDm: null, + isSwitching: false, + setIsSwitching: (value: boolean) => set((state) => ({ + chat: { + ...state.chat, + isSwitching: value + } + })), + activePanel: null, + publicChatPanel: null, + dmPanel: null, + pendingPanel: null, + profileDialog: null, + call: { + isActive: false, + status: "ended", + startTime: null, + isMuted: false, + remoteUserId: null, + remoteUsername: null, + isInitiator: false, + isMinimized: false, + sessionKeyHash: null, + encryptionEmojis: [], + isVideoEnabled: false, + isRemoteVideoEnabled: false, + isSharingScreen: false, + isRemoteScreenSharing: false + }, + onlineStatuses: new Map(), + typingUsers: new Map(), + dmTypingUsers: new Map() + }, + addMessage: (message: Message) => set((state) => { + const messageExists = state.chat.messages.some(msg => msg.id === message.id); + if (messageExists) { + return state; + } + + return { + chat: { + ...state.chat, + messages: [...state.chat.messages, message] + } + }; + }), + updateMessage: (messageId: number, updatedMessage: Partial) => set((state) => ({ + chat: { + ...state.chat, + messages: state.chat.messages.map(msg => + msg.id === messageId ? { ...msg, ...updatedMessage } : msg + ) + } + })), + removeMessage: (messageId: number) => set((state) => ({ + chat: { + ...state.chat, + messages: state.chat.messages.filter(msg => msg.id !== messageId) + } + })), + clearMessages: () => set((state) => ({ + chat: { + ...state.chat, + messages: [] + } + })), + setCurrentChat: (chat: string) => set((state) => ({ + chat: { + ...state.chat, + currentChat: chat + } + })), + setActiveTab: (tab: ChatTabs) => set((state) => ({ + chat: { + ...state.chat, + activeTab: tab + } + })), + setDmUsers: (users: User[]) => set((state) => ({ + chat: { + ...state.chat, + dmUsers: users + } + })), + setActiveDm: (dm: ActiveDM | null) => set((state) => ({ + chat: { + ...state.chat, + activeDm: dm + } + })), + setActivePanel: (panel: MessagePanel | null) => { + const state = get(); + if (state.chat.activePanel && state.chat.activePanel !== panel) { + state.chat.activePanel.deactivate(); + } + return set((state) => ({ + chat: { + ...state.chat, + activePanel: panel + } + })); + }, + setPendingPanel: (panel: MessagePanel | null) => set((state) => ({ + chat: { + ...state.chat, + pendingPanel: panel + } + })), + applyPendingPanel: () => { + const state = get(); + if (state.chat.activePanel) { + state.chat.activePanel.deactivate(); + } + return set((state) => ({ + chat: { + ...state.chat, + activePanel: state.chat.pendingPanel || state.chat.activePanel, + publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel) + ? (state.chat.pendingPanel as PublicChatPanel) + : state.chat.publicChatPanel, + dmPanel: (state.chat.pendingPanel instanceof DMPanel) + ? (state.chat.pendingPanel as DMPanel) + : state.chat.dmPanel, + currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat, + pendingPanel: null + } + })); + }, + switchToPublicChat: async (chatName: string) => { + const { chat } = get(); + const { user } = useUserStore(); + + if (!user.authToken) return; + + chat.setIsSwitching(true); + + let publicChatPanel = chat.publicChatPanel; + if (!publicChatPanel) { + publicChatPanel = new PublicChatPanel(chatName, user); + } else { + publicChatPanel.setChatName(chatName); + publicChatPanel.setAuthToken(user.authToken); + publicChatPanel.clearMessages(); + } + + await publicChatPanel.activate(); + + set((state) => ({ + chat: { + ...state.chat, + pendingPanel: publicChatPanel, + activeTab: "chats" + } + })); + }, + switchToDM: async (dmData: DMPanelData) => { + const { chat } = get(); + const { user } = useUserStore(); + + if (!user.authToken) return; + + chat.setIsSwitching(true); + + let dmPanel = chat.dmPanel; + if (!dmPanel) { + dmPanel = new DMPanel(user); + } else { + dmPanel.setAuthToken(user.authToken); + dmPanel.clearMessages(); + } + + dmPanel.setDMData(dmData); + + await dmPanel.activate(); + + set((state) => ({ + chat: { + ...state.chat, + pendingPanel: dmPanel, + activeDm: { + userId: dmData.userId, + username: dmData.username, + publicKey: dmData.publicKey + }, + activeTab: "chats" + } + })); + } +}); + diff --git a/frontend/src/pages/chat/state/types.ts b/frontend/src/pages/chat/state/types.ts new file mode 100644 index 0000000..caa3f29 --- /dev/null +++ b/frontend/src/pages/chat/state/types.ts @@ -0,0 +1,73 @@ +import type { Message, User } from "@/core/types"; +import { MessagePanel } from "../ui/right/panels/MessagePanel"; +import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel"; +import { DMPanel } from "../ui/right/panels/DMPanel"; + +export type ChatTabs = "chats" | "channels" | "contacts"; + +export type CallStatus = "calling" | "connecting" | "active" | "ended"; + +export interface ProfileDialogData { + userId?: number; + username?: string; + display_name?: string; + profilePicture?: string; + bio?: string; + memberSince?: string; + online?: boolean; + isOwnProfile: boolean; + verified?: boolean; + suspended?: boolean; + suspension_reason?: string | null; + deleted?: boolean; +} + +export interface ActiveDM { + userId: number; + username: string; + publicKey: string | null; +} + +export interface CallState { + isActive: boolean; + status: CallStatus; + startTime: number | null; + isMuted: boolean; + remoteUserId: number | null; + remoteUsername: string | null; + isInitiator: boolean; + isMinimized: boolean; + sessionKeyHash: string | null; + encryptionEmojis: string[]; + isVideoEnabled: boolean; + isRemoteVideoEnabled: boolean; + isSharingScreen: boolean; + isRemoteScreenSharing: boolean; +} + +export interface ChatState { + messages: Message[]; + currentChat: string; + activeTab: ChatTabs; + dmUsers: User[]; + activeDm: ActiveDM | null; + isSwitching: boolean; + setIsSwitching: (value: boolean) => void; + activePanel: MessagePanel | null; + publicChatPanel: PublicChatPanel | null; + dmPanel: DMPanel | null; + pendingPanel?: MessagePanel | null; + call: CallState; + profileDialog: ProfileDialogData | null; + onlineStatuses: Map; + typingUsers: Map; // userId -> username + dmTypingUsers: Map; +} + +export interface UserState { + currentUser: User | null; + authToken: string | null; + isSuspended: boolean; + suspensionReason: string | null; +} + diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index d940d78..5319a5c 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -1,5 +1,6 @@ import { MessagePanel } from "./MessagePanel"; import api from "@/core/api"; +import { decryptDm, sendDMViaWebSocket, sendDmWithFiles } from "@/core/api/dm"; import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; import type { UserState, ProfileDialogData } from "@/state/types"; import { formatDMUsername } from "@/pages/chat/hooks/useDM"; @@ -55,7 +56,7 @@ export class DMPanel extends MessagePanel { } private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) { - const plaintext = await api.chats.dm.decrypt(env, this.dmData!.publicKey); + const plaintext = await decryptDm(env, env.senderId); const username = formatDMUsername( env.senderId, env.recipientId, @@ -193,21 +194,24 @@ export class DMPanel extends MessagePanel { } const json = JSON.stringify(payload); - if (files.length === 0) { - await api.chats.dm.send( - this.dmData.userId, - this.dmData.publicKey, - json, - this.currentUser.authToken - ); - } else { - await api.chats.dm.sendWithFiles( - this.dmData.userId, - this.dmData.publicKey, - json, - files, - this.currentUser.authToken - ); + try { + if (files.length === 0) { + await sendDMViaWebSocket( + this.dmData.userId, + json, + this.currentUser.authToken + ); + } else { + await sendDmWithFiles( + this.dmData.userId, + this.dmData.publicKey, + json, + files, + this.currentUser.authToken + ); + } + } catch (error) { + console.error("Failed to send DM:", error); } } @@ -259,14 +263,14 @@ export class DMPanel extends MessagePanel { } } if (response.type === "dmEdited" && this.dmData) { - const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data; + const { id, senderId, recipientId, iv, ciphertext, salt, iv2, wrappedMk } = response.data; try { // Decrypt new content in-place - const plaintext = await api.chats.dm.decrypt( + const plaintext = await decryptDm( { id, - senderId: 0, - recipientId: 0, + senderId, + recipientId, iv, ciphertext, salt, @@ -274,7 +278,7 @@ export class DMPanel extends MessagePanel { wrappedMk, timestamp: new Date().toISOString() }, - this.dmData.publicKey + senderId ); let content = plaintext; let files: Message["files"] | undefined = undefined; diff --git a/frontend/src/utils/crypto/signalProtocol.ts b/frontend/src/utils/crypto/signalProtocol.ts new file mode 100644 index 0000000..7ef3376 --- /dev/null +++ b/frontend/src/utils/crypto/signalProtocol.ts @@ -0,0 +1,210 @@ +/** + * Signal Protocol service wrapper + * Provides high-level API for encrypting/decrypting messages using Signal Protocol + */ + +import { + SessionBuilder, + SessionCipher, + KeyHelper, + SignalProtocolAddress, + type DeviceType +} from "@privacyresearch/libsignal-protocol-typescript"; +import { SignalProtocolStorage } from "./signalStorage"; +import { b64, ub64 } from "../utils"; + +// Helper to ensure we get a proper ArrayBuffer (not SharedArrayBuffer) +function toArrayBuffer(buffer: ArrayBuffer | SharedArrayBuffer): ArrayBuffer { + if (buffer instanceof ArrayBuffer) return buffer; + // Convert SharedArrayBuffer to ArrayBuffer by copying + const view = new Uint8Array(buffer); + const copy = new Uint8Array(view.length); + copy.set(view); + return copy.buffer; +} + +export interface PreKeyBundleData { + registrationId: number; + identityKey: string; // base64 + signedPreKey: { + keyId: number; + publicKey: string; // base64 + signature: string; // base64 + }; + preKey?: { + keyId: number; + publicKey: string; // base64 + }; +} + +export class SignalProtocolService { + private storage: SignalProtocolStorage; + + constructor(userId: string) { + this.storage = new SignalProtocolStorage(userId); + } + + /** + * Initialize Signal Protocol for this user + * Generates identity keys, registration ID, and prekeys if they don't exist + */ + async initialize(): Promise { + // Check if already initialized + const existingIdentity = await this.storage.getIdentityKeyPair(); + if (existingIdentity) { + return; // Already initialized + } + + // Generate identity key pair + const identityKeyPair = await KeyHelper.generateIdentityKeyPair(); + await this.storage.saveIdentityKeyPair(identityKeyPair); + + // Generate registration ID + const registrationId = KeyHelper.generateRegistrationId(); + await this.storage.saveLocalRegistrationId(registrationId); + + // Generate signed prekey + const signedPreKeyId = 1; + const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, signedPreKeyId); + await this.storage.storeSignedPreKey(signedPreKeyId, signedPreKey.keyPair); + + // Store signature separately (we'll need it for the bundle) + // For now, we'll regenerate it when needed since storage doesn't store signatures + + // Generate prekeys (typically 100 prekeys) + const preKeyCount = 100; + for (let i = 1; i <= preKeyCount; i++) { + const preKey = await KeyHelper.generatePreKey(i); + await this.storage.storePreKey(i, preKey.keyPair); + } + } + + /** + * Get prekey bundle for this user to share with others + */ + async getPreKeyBundle(): Promise { + const identityKeyPair = await this.storage.getIdentityKeyPair(); + if (!identityKeyPair) { + throw new Error("Signal Protocol not initialized"); + } + + const registrationId = await this.storage.getLocalRegistrationId(); + if (!registrationId) { + throw new Error("Registration ID not found"); + } + + const signedPreKey = await this.storage.loadSignedPreKey(1); + if (!signedPreKey) { + throw new Error("Signed prekey not found"); + } + + // Regenerate signed prekey to get signature (since storage doesn't store it) + // In production, you'd store the signature separately + const signedPreKeyWithSig = await KeyHelper.generateSignedPreKey(identityKeyPair, 1); + await this.storage.storeSignedPreKey(1, signedPreKeyWithSig.keyPair); + + // Get a prekey to include + const preKey = await this.storage.loadPreKey(1); + if (!preKey) { + throw new Error("No prekeys available"); + } + + return { + registrationId: registrationId, + identityKey: b64(new Uint8Array(identityKeyPair.pubKey)), + signedPreKey: { + keyId: 1, + publicKey: b64(new Uint8Array(signedPreKey.pubKey)), + signature: b64(new Uint8Array(signedPreKeyWithSig.signature)) + }, + preKey: { + keyId: 1, + publicKey: b64(new Uint8Array(preKey.pubKey)) + } + }; + } + + /** + * Process a prekey bundle from another user and establish a session + */ + async processPreKeyBundle(recipientId: number, bundle: PreKeyBundleData): Promise { + const address = new SignalProtocolAddress(recipientId.toString(), 1); + + const identityKeyBuf = ub64(bundle.identityKey); + const signedPreKeyPubBuf = ub64(bundle.signedPreKey.publicKey); + const signedPreKeySigBuf = ub64(bundle.signedPreKey.signature); + + const deviceBundle: DeviceType = { + identityKey: toArrayBuffer(identityKeyBuf.buffer.slice(identityKeyBuf.byteOffset, identityKeyBuf.byteOffset + identityKeyBuf.byteLength)), + signedPreKey: { + keyId: bundle.signedPreKey.keyId, + publicKey: toArrayBuffer(signedPreKeyPubBuf.buffer.slice(signedPreKeyPubBuf.byteOffset, signedPreKeyPubBuf.byteOffset + signedPreKeyPubBuf.byteLength)), + signature: toArrayBuffer(signedPreKeySigBuf.buffer.slice(signedPreKeySigBuf.byteOffset, signedPreKeySigBuf.byteOffset + signedPreKeySigBuf.byteLength)) + }, + preKey: bundle.preKey ? { + keyId: bundle.preKey.keyId, + publicKey: (() => { + const preKeyBuf = ub64(bundle.preKey!.publicKey); + return toArrayBuffer(preKeyBuf.buffer.slice(preKeyBuf.byteOffset, preKeyBuf.byteOffset + preKeyBuf.byteLength)); + })() + } : undefined, + registrationId: bundle.registrationId + }; + + const sessionBuilder = new SessionBuilder(this.storage, address); + await sessionBuilder.processPreKey(deviceBundle); + } + + /** + * Encrypt a message for a recipient + */ + async encryptMessage(recipientId: number, plaintext: string): Promise<{ type: number; body: string }> { + const address = new SignalProtocolAddress(recipientId.toString(), 1); + + const sessionCipher = new SessionCipher(this.storage, address); + const plaintextBuffer = toArrayBuffer(new TextEncoder().encode(plaintext).buffer); + const { type, body } = await sessionCipher.encrypt(plaintextBuffer); + + if (!body) { + throw new Error("Encryption failed: no body in ciphertext"); + } + + // ciphertext.body is a base64 string, but we need to convert it properly + // According to the library, body is a serialized protobuf message as base64 string + return { type, body }; + } + + /** + * Decrypt a message from a sender + */ + async decryptMessage(senderId: number, ciphertext: { type: number; body: string }): Promise { + const address = new SignalProtocolAddress(senderId.toString(), 1); + + const sessionCipher = new SessionCipher(this.storage, address); + + // Handle both PreKeyWhisperMessage (type 3) and WhisperMessage (type 1) + const { buffer, byteOffset, byteLength } = ub64(ciphertext.body); + const bodyBuffer = toArrayBuffer(buffer.slice(byteOffset, byteOffset + byteLength)); + let plaintextBytes: ArrayBuffer; + + if (ciphertext.type === 3) { + // PreKeyWhisperMessage + plaintextBytes = await sessionCipher.decryptPreKeyWhisperMessage(bodyBuffer); + } else { + // WhisperMessage + plaintextBytes = await sessionCipher.decryptWhisperMessage(bodyBuffer); + } + + return new TextDecoder().decode(plaintextBytes); + } + + /** + * Check if a session exists for a recipient + */ + async hasSession(recipientId: number): Promise { + const address = new SignalProtocolAddress(recipientId.toString(), 1); + const sessionCipher = new SessionCipher(this.storage, address); + return await sessionCipher.hasOpenSession(); + } +} + diff --git a/frontend/src/utils/crypto/signalStorage.ts b/frontend/src/utils/crypto/signalStorage.ts new file mode 100644 index 0000000..de81051 --- /dev/null +++ b/frontend/src/utils/crypto/signalStorage.ts @@ -0,0 +1,295 @@ +/** + * IndexedDB storage implementation for Signal Protocol + * Stores identity keys, prekeys, signed prekeys, and session states + */ + +import type { StorageType, KeyPairType, Direction } from "@privacyresearch/libsignal-protocol-typescript"; + +const DB_NAME = "signal_protocol_db"; +const DB_VERSION = 1; + +interface SignalDB { + identityKeys: IDBObjectStore; + preKeys: IDBObjectStore; + signedPreKeys: IDBObjectStore; + sessions: IDBObjectStore; + registrationId: IDBObjectStore; +} + +let dbPromise: Promise | null = null; + +function openDB(): Promise { + if (dbPromise) return dbPromise; + + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Identity keys store: key = userId, value = { publicKey, privateKey } + if (!db.objectStoreNames.contains("identityKeys")) { + db.createObjectStore("identityKeys", { keyPath: "userId" }); + } + + // Prekeys store: key = userId + preKeyId, value = { userId, preKeyId, publicKey, privateKey } + if (!db.objectStoreNames.contains("preKeys")) { + const preKeysStore = db.createObjectStore("preKeys", { keyPath: ["userId", "preKeyId"] }); + preKeysStore.createIndex("userId", "userId", { unique: false }); + } + + // Signed prekeys store: key = userId, value = { userId, keyId, publicKey, privateKey, signature } + if (!db.objectStoreNames.contains("signedPreKeys")) { + db.createObjectStore("signedPreKeys", { keyPath: "userId" }); + } + + // Sessions store: key = userId + deviceId, value = { userId, deviceId, record } + if (!db.objectStoreNames.contains("sessions")) { + const sessionsStore = db.createObjectStore("sessions", { keyPath: ["userId", "deviceId"] }); + sessionsStore.createIndex("userId", "userId", { unique: false }); + } + + // Registration ID store: key = userId, value = { userId, registrationId } + if (!db.objectStoreNames.contains("registrationId")) { + db.createObjectStore("registrationId", { keyPath: "userId" }); + } + }; + }); + + return dbPromise; +} + +async function getStore(storeName: keyof SignalDB, mode: IDBTransactionMode = "readonly"): Promise { + const db = await openDB(); + const tx = db.transaction([storeName], mode); + return tx.objectStore(storeName); +} + +// Helper to convert Uint8Array to ArrayBuffer +function toArrayBuffer(u8: Uint8Array | ArrayBuffer): ArrayBuffer { + if (u8 instanceof ArrayBuffer) return u8; + return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength); +} + +// Helper to convert ArrayBuffer to Uint8Array +function toUint8Array(ab: ArrayBuffer | Uint8Array): Uint8Array { + if (ab instanceof Uint8Array) return ab; + return new Uint8Array(ab); +} + +export class SignalProtocolStorage implements StorageType { + private userId: string; + + constructor(userId: string) { + this.userId = userId; + } + + // Identity Key Management + async getIdentityKeyPair(): Promise { + const store = await getStore("identityKeys"); + const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer } | undefined>((resolve, reject) => { + const request = store.get(this.userId); + request.onsuccess = () => { + const data = request.result; + if (!data) { + resolve(undefined); + return; + } + resolve({ + pubKey: toArrayBuffer(data.publicKey), + privKey: toArrayBuffer(data.privateKey) + }); + }; + request.onerror = () => reject(request.error); + }); + + return result; + } + + async getLocalRegistrationId(): Promise { + const store = await getStore("registrationId"); + const result = await new Promise<{ registrationId: number } | undefined>((resolve, reject) => { + const request = store.get(this.userId); + request.onsuccess = () => { + const data = request.result; + resolve(data ? { registrationId: data.registrationId } : undefined); + }; + request.onerror = () => reject(request.error); + }); + + return result?.registrationId; + } + + async isTrustedIdentity(identifier: string, identityKey: ArrayBuffer, direction: Direction): Promise { + // For now, always trust (can be enhanced with key verification) + // In production, you'd check against previously stored identity keys + return true; + } + + async saveIdentity(encodedAddress: string, publicKey: ArrayBuffer, nonblockingApproval?: boolean): Promise { + // Store other users' identity keys if needed + // For now, we trust all identities + return true; + } + + // Helper methods for initialization (not part of StorageType interface) + async saveIdentityKeyPair(keyPair: KeyPairType): Promise { + const store = await getStore("identityKeys", "readwrite"); + await new Promise((resolve, reject) => { + const request = store.put({ + userId: this.userId, + publicKey: toUint8Array(keyPair.pubKey), + privateKey: toUint8Array(keyPair.privKey) + }); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + async saveLocalRegistrationId(registrationId: number): Promise { + const store = await getStore("registrationId", "readwrite"); + await new Promise((resolve, reject) => { + const request = store.put({ + userId: this.userId, + registrationId: registrationId + }); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + // PreKey Management + async loadPreKey(encodedAddress: string | number): Promise { + const preKeyId = typeof encodedAddress === "number" ? encodedAddress : parseInt(encodedAddress, 10); + const store = await getStore("preKeys"); + const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer } | undefined>((resolve, reject) => { + const request = store.get([this.userId, preKeyId]); + request.onsuccess = () => { + const data = request.result; + if (!data) { + resolve(undefined); + return; + } + resolve({ + pubKey: toArrayBuffer(data.publicKey), + privKey: toArrayBuffer(data.privateKey) + }); + }; + request.onerror = () => reject(request.error); + }); + + return result; + } + + async storePreKey(keyId: number | string, keyPair: KeyPairType): Promise { + const preKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10); + const store = await getStore("preKeys", "readwrite"); + await new Promise((resolve, reject) => { + const request = store.put({ + userId: this.userId, + preKeyId: preKeyId, + publicKey: toUint8Array(keyPair.pubKey), + privateKey: toUint8Array(keyPair.privKey) + }); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + async removePreKey(keyId: number | string): Promise { + const preKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10); + const store = await getStore("preKeys", "readwrite"); + await new Promise((resolve, reject) => { + const request = store.delete([this.userId, preKeyId]); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + // Signed PreKey Management + async loadSignedPreKey(keyId: number | string): Promise { + const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10); + const store = await getStore("signedPreKeys"); + const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer; keyId: number } | undefined>((resolve, reject) => { + const request = store.get(this.userId); + request.onsuccess = () => { + const data = request.result; + if (!data || data.keyId !== signedPreKeyId) { + resolve(undefined); + return; + } + resolve({ + pubKey: toArrayBuffer(data.publicKey), + privKey: toArrayBuffer(data.privateKey) + }); + }; + request.onerror = () => reject(request.error); + }); + + return result; + } + + async storeSignedPreKey(keyId: number | string, keyPair: KeyPairType): Promise { + const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10); + const store = await getStore("signedPreKeys", "readwrite"); + await new Promise((resolve, reject) => { + const request = store.put({ + userId: this.userId, + keyId: signedPreKeyId, + publicKey: toUint8Array(keyPair.pubKey), + privateKey: toUint8Array(keyPair.privKey) + }); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + async removeSignedPreKey(keyId: number | string): Promise { + const store = await getStore("signedPreKeys", "readwrite"); + await new Promise((resolve, reject) => { + const request = store.delete(this.userId); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + // Session Management + async loadSession(encodedAddress: string): Promise { + // encodedAddress format: "userId.deviceId" + const parts = encodedAddress.split("."); + const deviceId = parts.length > 1 ? parts[1] : encodedAddress; + + const store = await getStore("sessions"); + const result = await new Promise<{ record: string } | undefined>((resolve, reject) => { + const request = store.get([this.userId, deviceId]); + request.onsuccess = () => { + const data = request.result; + resolve(data ? data.record : undefined); + }; + request.onerror = () => reject(request.error); + }); + + return result; + } + + async storeSession(encodedAddress: string, record: string): Promise { + // encodedAddress format: "userId.deviceId" + const parts = encodedAddress.split("."); + const deviceId = parts.length > 1 ? parts[1] : encodedAddress; + + const store = await getStore("sessions", "readwrite"); + await new Promise((resolve, reject) => { + const request = store.put({ + userId: this.userId, + deviceId: deviceId, + record: record + }); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } +} + diff --git a/package.json b/package.json index b679225..12ce686 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "vite-plugin-sass-dts": "^1.3.34" }, "dependencies": { + "@privacyresearch/libsignal-protocol-typescript": "^0.0.16", "electron-squirrel-startup": "^1.0.1", "escape-string-regexp": "^5.0.0", "he": "^1.2.0",