diff --git a/backend/models.py b/backend/models.py index 7d72ac9..7de6175 100644 --- a/backend/models.py +++ b/backend/models.py @@ -93,6 +93,19 @@ class SignalPreKey(Base): __table_args__ = (UniqueConstraint('user_id', 'prekey_id', name='_user_prekey_uc'),) +class SignalSession(Base): + __tablename__ = "signal_session" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) + recipient_id = Column(Integer, nullable=False, index=True) # The other user in the session + device_id = Column(Integer, default=1, nullable=False) # Device ID (always 1 for now) + encrypted_session_data = Column(Text, nullable=False) # Encrypted session record (JSON with salt, iv, ciphertext) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + __table_args__ = (UniqueConstraint('user_id', 'recipient_id', 'device_id', name='_user_recipient_device_uc'),) + + class DMEnvelope(Base): __tablename__ = "dm_envelope" diff --git a/backend/routes/account.py b/backend/routes/account.py index ee2905e..3c1d39b 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -10,7 +10,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from constants import OWNER_USERNAME from dependencies import get_current_user, get_db -from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession +from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession, SignalSession from utils import create_token, get_password_hash, verify_password, get_client_ip from validation import is_valid_password, is_valid_username, is_valid_display_name import os @@ -707,6 +707,89 @@ def get_prekey_bundle_of( raise HTTPException(status_code=500, detail="Invalid bundle data") +@router.post("/crypto/signal/sessions") +@rate_limit_per_ip("100/minute") +def upload_signal_sessions( + request: Request, + payload: dict, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Upload encrypted Signal Protocol sessions for the current user""" + import json + from datetime import datetime + + sessions = payload.get("sessions") + if not isinstance(sessions, list): + raise HTTPException(status_code=400, detail="sessions must be a list") + + uploaded_count = 0 + for session_data in sessions: + if not isinstance(session_data, dict): + continue + + recipient_id = session_data.get("recipientId") + device_id = session_data.get("deviceId", 1) + encrypted_data = session_data.get("encryptedData") + + if not recipient_id or not encrypted_data: + continue + + try: + # Validate encrypted_data is valid JSON + json.loads(encrypted_data) + except (json.JSONDecodeError, TypeError): + continue + + # Store or update session + existing = db.query(SignalSession).filter( + SignalSession.user_id == current_user.id, + SignalSession.recipient_id == recipient_id, + SignalSession.device_id == device_id + ).first() + + if existing: + existing.encrypted_session_data = encrypted_data + existing.updated_at = datetime.now() + else: + new_session = SignalSession( + user_id=current_user.id, + recipient_id=recipient_id, + device_id=device_id, + encrypted_session_data=encrypted_data + ) + db.add(new_session) + uploaded_count += 1 + + db.commit() + return {"status": "ok", "uploaded_count": uploaded_count} + + +@router.get("/crypto/signal/sessions") +@rate_limit_per_ip("60/minute") +def get_signal_sessions( + request: Request, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get all encrypted Signal Protocol sessions for the current user""" + sessions = db.query(SignalSession).filter( + SignalSession.user_id == current_user.id + ).all() + + return { + "sessions": [ + { + "recipientId": s.recipient_id, + "deviceId": s.device_id, + "encryptedData": s.encrypted_session_data, + "updatedAt": s.updated_at.isoformat() + } + for s in sessions + ] + } + + @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/chats/dm.ts b/frontend/src/core/api/chats/dm.ts index d74cacb..6a39125 100644 --- a/frontend/src/core/api/chats/dm.ts +++ b/frontend/src/core/api/chats/dm.ts @@ -132,8 +132,17 @@ export async function decrypt(envelope: DmEnvelope, senderId: number): Promise { @@ -158,7 +167,13 @@ export async function send(recipientId: number, plaintext: string, authToken: st 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); + let hasSession = false; + try { + hasSession = await signalService.hasSession(recipientId); + } catch (error) { + console.warn("Failed to check session, will attempt to establish new one:", error); + } + if (!hasSession) { try { const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken); @@ -168,13 +183,27 @@ export async function send(recipientId: number, plaintext: string, authToken: st if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) { throw error; } + // Log other errors for debugging + console.error("Failed to establish session:", { + recipientId, + error: error instanceof Error ? error.message : String(error) + }); // Re-throw other errors throw error; } } // Encrypt with Signal Protocol - const ciphertext = await signalService.encryptMessage(recipientId, plaintext); + let ciphertext: { type: number; body: string }; + try { + ciphertext = await signalService.encryptMessage(recipientId, plaintext); + } catch (error) { + console.error("Failed to encrypt message:", { + recipientId, + error: error instanceof Error ? error.message : String(error) + }); + throw error; + } // Verify the body is valid base64 before stringifying if (ciphertext.body && typeof ciphertext.body === "string") { @@ -214,7 +243,7 @@ export async function send(recipientId: number, plaintext: string, authToken: st // Add padding to obfuscate message size (anti-censorship) const paddedCiphertext = addPadding(ciphertextJson); - + const payload: SendDMRequest = { recipientId: recipientId, iv: "", // Not used for Signal Protocol diff --git a/frontend/src/core/api/crypto/sessions.ts b/frontend/src/core/api/crypto/sessions.ts new file mode 100644 index 0000000..590cd60 --- /dev/null +++ b/frontend/src/core/api/crypto/sessions.ts @@ -0,0 +1,53 @@ +/** + * API functions for managing Signal Protocol sessions on the server + */ + +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; + +export interface SessionData { + recipientId: number; + deviceId: number; + encryptedData: string; // JSON string of encrypted session +} + +/** + * Upload encrypted Signal Protocol sessions to the server + */ +export async function uploadSessions(sessions: SessionData[], token: string): Promise { + const headers = getAuthHeaders(token, true); + + const payload = { + sessions + }; + + const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + throw new Error(`Failed to upload sessions: ${res.statusText}`); + } +} + +/** + * Fetch all encrypted Signal Protocol sessions from the server + */ +export async function fetchSessions(token: string): Promise { + const headers = getAuthHeaders(token, true); + + const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, { + method: "GET", + headers + }); + + if (!res.ok) { + throw new Error(`Failed to fetch sessions: ${res.statusText}`); + } + + const data = await res.json(); + return data.sessions || []; +} + diff --git a/frontend/src/core/api/index.ts b/frontend/src/core/api/index.ts index dbeb1be..b7e5938 100644 --- a/frontend/src/core/api/index.ts +++ b/frontend/src/core/api/index.ts @@ -7,6 +7,7 @@ import * as userSearch from "./user/search"; import * as cryptoPrekeys from "./crypto/prekeys"; import * as cryptoIdentity from "./crypto/identity"; import * as cryptoBackup from "./crypto/backup"; +import * as cryptoSessions from "./crypto/sessions"; import * as moderationBlocklist from "./moderation/blocklist"; import * as moderationUsers from "./moderation/users"; import * as callsModule from "./calls"; @@ -27,7 +28,8 @@ const api = { crypto: { prekeys: cryptoPrekeys, identity: cryptoIdentity, - backup: cryptoBackup + backup: cryptoBackup, + sessions: cryptoSessions }, moderation: { blocklist: moderationBlocklist, diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index d72e4fc..fd9bc58 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -98,10 +98,22 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { // Run Signal Protocol initialization in background to avoid blocking navigation (async () => { try { + console.log("Starting Signal Protocol initialization..."); const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol"); const { uploadPreKeyBundle, uploadAllPreKeys } = await import("@/core/api/crypto/prekeys"); + const { restoreSessionsFromServer, uploadAllSessionsToServer, initializeSessionSync } = await import("@/utils/crypto/sessionSync"); + const signalService = new SignalProtocolService(data.user!.id.toString()); await signalService.initialize(); + console.log("Signal Protocol initialized"); + + // Initialize session sync (enables automatic upload of new sessions) + console.log("Initializing session sync..."); + initializeSessionSync(data.user!.id.toString(), password, data.token); + console.log("Session sync initialized"); + + // Restore sessions from server (encrypted with password) + await restoreSessionsFromServer(data.user!.id.toString(), password, data.token); // Upload base bundle with one prekey (for backward compatibility) const bundle = await signalService.getPreKeyBundle(); @@ -112,6 +124,9 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { const prekeys = await signalService.getAllPreKeys(); await uploadAllPreKeys(baseBundle, prekeys, data.token); + // Upload all current sessions to server (backup) + await uploadAllSessionsToServer(data.user!.id.toString(), password, data.token); + console.log(`Uploaded ${prekeys.length} prekeys to server`); } catch (e) { console.error("Key setup failed:", e); diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx index 81fdfa4..b6d2328 100644 --- a/frontend/src/pages/auth/RegisterForm.tsx +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -120,6 +120,40 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) { try { await api.user.auth.ensureKeysOnLogin(password, data.token); + + // Initialize Signal Protocol after keys are set up (non-blocking) + if (data.user?.id) { + // Run Signal Protocol initialization in background to avoid blocking navigation + (async () => { + try { + const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol"); + const { uploadPreKeyBundle, uploadAllPreKeys } = await import("@/core/api/crypto/prekeys"); + const { uploadAllSessionsToServer, initializeSessionSync } = await import("@/utils/crypto/sessionSync"); + + const signalService = new SignalProtocolService(data.user!.id.toString()); + await signalService.initialize(); + + // Initialize session sync (enables automatic upload of new sessions) + initializeSessionSync(data.user!.id.toString(), password, data.token); + + // Upload base bundle with one prekey (for backward compatibility) + const bundle = await signalService.getPreKeyBundle(); + await uploadPreKeyBundle(bundle, data.token); + + // Upload all prekeys for server-side rotation + const baseBundle = await signalService.getBaseBundle(); + const prekeys = await signalService.getAllPreKeys(); + await uploadAllPreKeys(baseBundle, prekeys, data.token); + + // Upload all current sessions to server (backup - will be empty on registration) + await uploadAllSessionsToServer(data.user!.id.toString(), password, data.token); + + console.log(`Uploaded ${prekeys.length} prekeys to server`); + } catch (e) { + console.error("Key setup failed:", e); + } + })(); + } } 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 9b3e62b..008cce4 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -166,8 +166,21 @@ export function useDM() { for (const env of messages) { try { - const text = await decryptDm(env, env.senderId); - const isAuthor = env.senderId !== userId; + // Check if this is a message sent by the current user + const isAuthor = env.senderId === user.currentUser?.id; + let text: string; + + if (isAuthor) { + // For sent messages, we can't decrypt them in Signal Protocol + // The plaintext should be stored when sending, but for now we'll skip them + // or try to get it from the envelope if available + // Skip this message for now - we'll need to store plaintext when sending + continue; // Skip sent messages - they'll be handled by the send flow + } else { + // Decrypt incoming messages + text = await decryptDm(env, env.senderId); + } + const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User"; decryptedMessages.push({ diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index f2c5f15..2f3ff9a 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -58,7 +58,20 @@ export class DMPanel extends MessagePanel { } private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) { - const plaintext = await decryptDm(env, env.senderId); + // Check if this is a message sent by the current user + const isSentByUs = env.senderId === this.currentUser.currentUser?.id; + + let plaintext: string; + if (isSentByUs) { + // Can't decrypt our own sent messages in Signal Protocol + // The plaintext should be stored when sending, but for now we'll skip it + // This message should have been displayed immediately when sent + throw new Error("Cannot decrypt own sent message - should be displayed from send flow"); + } else { + // Decrypt incoming messages + plaintext = await decryptDm(env, env.senderId); + } + const username = formatDMUsername( env.senderId, env.recipientId, diff --git a/frontend/src/state/user.ts b/frontend/src/state/user.ts index 8c8e036..c437fff 100644 --- a/frontend/src/state/user.ts +++ b/frontend/src/state/user.ts @@ -7,6 +7,7 @@ import { isElectron } from "@/core/electron/electron"; import { onlineStatusManager } from "@/core/onlineStatusManager"; import { typingManager } from "@/core/typingManager"; import type { UserState } from "./types"; +import { clearSessionSync } from "@/utils/crypto/sessionSync"; interface UserStore { user: UserState; @@ -58,6 +59,9 @@ export const useUserStore = create((set) => ({ typingManager.setAuthToken(null); onlineStatusManager.cleanup(); typingManager.cleanup(); + + // Clear session sync + clearSessionSync(); set({ user: { @@ -104,6 +108,22 @@ export const useUserStore = create((set) => ({ onlineStatusManager.setAuthToken(token); typingManager.setAuthToken(token); + // Initialize Signal Protocol after restoring user (non-blocking) + // Note: We can't restore sessions without the password, but we can initialize + // Signal Protocol so new sessions can be created when needed + if (user.id) { + (async () => { + try { + const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol"); + const signalService = new SignalProtocolService(user.id.toString()); + await signalService.initialize(); + console.log("Signal Protocol initialized after restore (sessions will be re-established when needed)"); + } catch (e) { + console.error("Signal Protocol initialization failed (restored):", e); + } + })(); + } + // Ping will be sent automatically on WebSocket reconnect // No need to send here to avoid duplicate pings diff --git a/frontend/src/utils/crypto/sessionEncryption.ts b/frontend/src/utils/crypto/sessionEncryption.ts new file mode 100644 index 0000000..907b387 --- /dev/null +++ b/frontend/src/utils/crypto/sessionEncryption.ts @@ -0,0 +1,60 @@ +/** + * Functions for encrypting/decrypting Signal Protocol session data + * Uses password-derived key (same as backup encryption) + */ + +import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric"; +import { importPassword, deriveKEK, randomBytes } from "./kdf"; +import { b64, ub64 } from "../utils"; + +export interface EncryptedSessionData { + salt: Uint8Array; // for PBKDF2 derivation of KEK + iv: Uint8Array; // AES-GCM IV + ciphertext: Uint8Array; // encrypted session record (string) +} + +/** + * Encrypt session record using password-derived key + */ +export async function encryptSessionWithPassword(password: string, sessionRecord: string): Promise { + const salt = randomBytes(16); + const pw = await importPassword(password); + const kek = await deriveKEK(pw, salt); + const sessionBytes = new TextEncoder().encode(sessionRecord); + const { iv, ciphertext } = await aesGcmEncrypt(kek, sessionBytes); + return { salt, iv, ciphertext }; +} + +/** + * Decrypt session record using password-derived key + */ +export async function decryptSessionWithPassword(password: string, blob: EncryptedSessionData): Promise { + const pw = await importPassword(password); + const kek = await deriveKEK(pw, blob.salt); + const plaintext = await aesGcmDecrypt(kek, blob.iv, blob.ciphertext); + return new TextDecoder().decode(plaintext); +} + +/** + * Encode encrypted session data to JSON string for storage + */ +export function encodeSessionBlob(blob: EncryptedSessionData): string { + return JSON.stringify({ + salt: b64(blob.salt), + iv: b64(blob.iv), + ciphertext: b64(blob.ciphertext) + }); +} + +/** + * Decode encrypted session data from JSON string + */ +export function decodeSessionBlob(json: string): EncryptedSessionData { + const obj = JSON.parse(json); + return { + salt: ub64(obj.salt), + iv: ub64(obj.iv), + ciphertext: ub64(obj.ciphertext) + }; +} + diff --git a/frontend/src/utils/crypto/sessionSync.ts b/frontend/src/utils/crypto/sessionSync.ts new file mode 100644 index 0000000..f789520 --- /dev/null +++ b/frontend/src/utils/crypto/sessionSync.ts @@ -0,0 +1,268 @@ +/** + * Service for syncing Signal Protocol sessions with the server + * Sessions are encrypted with password-derived key and stored on server + */ + +import { SignalProtocolStorage, setSessionSyncCallback, setRestoring } from "./signalStorage"; +import { encryptSessionWithPassword, decryptSessionWithPassword, encodeSessionBlob, decodeSessionBlob } from "./sessionEncryption"; +import { uploadSessions, fetchSessions, type SessionData } from "@/core/api/crypto/sessions"; + +// Global state for session sync +let syncPassword: string | null = null; +let syncToken: string | null = null; +let syncUserId: string | null = null; + +/** + * Initialize session sync - sets up automatic upload of sessions when they're created + * Called after login when password is available + */ +export function initializeSessionSync(userId: string, password: string, token: string): void { + console.log("Initializing session sync for user", userId); + syncUserId = userId; + syncPassword = password; + syncToken = token; + + // Set up callback to upload sessions when they're stored + setSessionSyncCallback(async (address: string, record: string) => { + console.log(`Session sync callback invoked for address: ${address}`); + if (!syncPassword || !syncToken || !syncUserId) { + console.warn("Session sync not initialized (missing password/token/userId)"); + return; // Not initialized yet + } + + try { + const parts = address.split("."); + const recipientId = parseInt(parts[0], 10); + const deviceId = parts.length > 1 ? parseInt(parts[1], 10) : 1; + + if (isNaN(recipientId)) { + console.warn(`Invalid address format: ${address}`); + return; + } + + console.log(`Encrypting session for recipient ${recipientId}...`); + const encryptedBlob = await encryptSessionWithPassword(syncPassword, record); + const encryptedData = encodeSessionBlob(encryptedBlob); + + console.log(`Uploading session for recipient ${recipientId} to server...`); + await uploadSessions([ + { + recipientId, + deviceId, + encryptedData + } + ], syncToken); + console.log(`Successfully uploaded session for recipient ${recipientId} to server`); + } catch (error) { + console.error("Failed to sync session to server:", error); + // Don't throw - session is already stored in IndexedDB, sync failure shouldn't break anything + } + }); + console.log("Session sync callback set successfully"); +} + +/** + * Clear session sync - called on logout + */ +export function clearSessionSync(): void { + syncUserId = null; + syncPassword = null; + syncToken = null; + setSessionSyncCallback(null); +} + +/** + * Restore all sessions from server and populate IndexedDB + * Called after login when password is available + */ +export async function restoreSessionsFromServer( + userId: string, + password: string, + token: string +): Promise { + try { + console.log("Restoring sessions from server..."); + + // Fetch encrypted sessions from server + const encryptedSessions = await fetchSessions(token); + + if (encryptedSessions.length === 0) { + console.log("No sessions to restore from server"); + return; // No sessions to restore + } + + console.log(`Found ${encryptedSessions.length} sessions on server, restoring...`); + + const storage = new SignalProtocolStorage(userId); + + // Set restoring flag to prevent sync callback from re-uploading restored sessions + setRestoring(true); + + let restoredCount = 0; + let failedCount = 0; + + try { + // Decrypt and restore each session + for (const sessionData of encryptedSessions) { + try { + const address = `${sessionData.recipientId}.${sessionData.deviceId}`; + + // Check if we already have a local session - if so, skip restoration + // This prevents overwriting a newer session with an older one + const existingSession = await storage.loadSession(address); + if (existingSession) { + console.log(`Skipping restoration for recipient ${sessionData.recipientId} - local session already exists`); + continue; + } + + const encryptedBlob = decodeSessionBlob(sessionData.encryptedData); + const sessionRecord = await decryptSessionWithPassword(password, encryptedBlob); + + // Store in IndexedDB (sync callback won't fire because isRestoring is true) + await storage.storeSession(address, sessionRecord); + restoredCount++; + console.log(`Restored session for recipient ${sessionData.recipientId}`); + } catch (error) { + failedCount++; + console.warn(`Failed to restore session for recipient ${sessionData.recipientId}:`, error); + // Continue with other sessions + } + } + } finally { + // Always clear the restoring flag + setRestoring(false); + } + + console.log(`Restored ${restoredCount}/${encryptedSessions.length} sessions from server${failedCount > 0 ? ` (${failedCount} failed)` : ""}`); + } catch (error) { + console.error("Failed to restore sessions from server:", error); + // Don't throw - allow login to continue even if session restore fails + } +} + +/** + * Upload all sessions to server + * Called after login/registration to backup all current sessions + */ +export async function uploadAllSessionsToServer( + userId: string, + password: string, + token: string +): Promise { + try { + console.log("Uploading sessions to server..."); + + const storage = new SignalProtocolStorage(userId); + + // Get all sessions from IndexedDB + const sessions = await storage.getAllSessions(); + + if (sessions.length === 0) { + console.log("No sessions in IndexedDB to upload"); + return; // No sessions to upload + } + + console.log(`Found ${sessions.length} sessions in IndexedDB, uploading...`); + + // Encrypt and prepare sessions for upload + const sessionData: SessionData[] = []; + let failedCount = 0; + + for (const { address, record } of sessions) { + try { + // Parse address to get recipientId and deviceId + const parts = address.split("."); + const recipientId = parseInt(parts[0], 10); + const deviceId = parts.length > 1 ? parseInt(parts[1], 10) : 1; + + if (isNaN(recipientId)) { + console.warn(`Invalid address format: ${address}`); + failedCount++; + continue; + } + + // Encrypt session record + const encryptedBlob = await encryptSessionWithPassword(password, record); + const encryptedData = encodeSessionBlob(encryptedBlob); + + sessionData.push({ + recipientId, + deviceId, + encryptedData + }); + } catch (error) { + failedCount++; + console.warn(`Failed to encrypt session ${address}:`, error); + // Continue with other sessions + } + } + + if (sessionData.length > 0) { + await uploadSessions(sessionData, token); + console.log(`Uploaded ${sessionData.length} sessions to server${failedCount > 0 ? ` (${failedCount} failed)` : ""}`); + } else if (failedCount > 0) { + console.warn(`Failed to upload all ${sessions.length} sessions to server`); + } + } catch (error) { + console.error("Failed to upload sessions to server:", error); + // Don't throw - allow login to continue even if upload fails + } +} + +/** + * Store a session in IndexedDB and upload to server + * This should be called instead of direct storage.storeSession when password is available + */ +export async function storeSessionWithSync( + userId: string, + address: string, + record: string, + password: string, + token: string +): Promise { + const storage = new SignalProtocolStorage(userId); + + // Store in IndexedDB first (for immediate use) + await storage.storeSession(address, record); + + // Parse address to get recipientId and deviceId + const parts = address.split("."); + const recipientId = parseInt(parts[0], 10); + const deviceId = parts.length > 1 ? parseInt(parts[1], 10) : 1; + + if (isNaN(recipientId)) { + console.warn(`Invalid address format: ${address}`); + return; + } + + // Encrypt and upload to server + try { + const encryptedBlob = await encryptSessionWithPassword(password, record); + const encryptedData = encodeSessionBlob(encryptedBlob); + + await uploadSessions([ + { + recipientId, + deviceId, + encryptedData + } + ], token); + } catch (error) { + console.error("Failed to upload session to server:", error); + // Don't throw - session is still stored in IndexedDB + } +} + +/** + * Remove a session from IndexedDB + * Note: We don't remove from server immediately because we need password for encryption. + * Stale sessions on server will be overwritten on next login when we upload all current sessions. + */ +export async function removeSessionLocal( + userId: string, + address: string +): Promise { + const storage = new SignalProtocolStorage(userId); + await storage.removeSession(address); +} + diff --git a/frontend/src/utils/crypto/signalProtocol.ts b/frontend/src/utils/crypto/signalProtocol.ts index ff727b7..d695acb 100644 --- a/frontend/src/utils/crypto/signalProtocol.ts +++ b/frontend/src/utils/crypto/signalProtocol.ts @@ -295,16 +295,17 @@ export class SignalProtocolService { * Encrypt a message for a recipient */ async encryptMessage(recipientId: number, plaintext: string): Promise<{ type: number; body: string }> { - const address = new SignalProtocolAddress(recipientId.toString(), 1); + try { + const address = new SignalProtocolAddress(recipientId.toString(), 1); - const sessionCipher = new SessionCipher(this.storage, address); - const plaintextBuffer = toArrayBuffer(new TextEncoder().encode(plaintext).buffer); - const encryptResult = await sessionCipher.encrypt(plaintextBuffer); - const { type, body } = encryptResult; + const sessionCipher = new SessionCipher(this.storage, address); + const plaintextBuffer = toArrayBuffer(new TextEncoder().encode(plaintext).buffer); + const encryptResult = await sessionCipher.encrypt(plaintextBuffer); + const { type, body } = encryptResult; - if (!body) { - throw new Error("Encryption failed: no body in ciphertext"); - } + if (!body) { + throw new Error("Encryption failed: no body in ciphertext"); + } // The library returns body as ArrayBuffer or Uint8Array, we need to convert it to base64 string // Always convert to Uint8Array first, then to base64, regardless of input type @@ -351,6 +352,16 @@ export class SignalProtocolService { } return { type, body: bodyBase64 }; + } catch (error) { + // Log detailed error information for debugging + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("Signal Protocol encryption failed:", { + recipientId, + plaintextLength: plaintext.length, + error: errorMessage + }); + throw new Error(`Failed to encrypt message: ${errorMessage}`); + } } /** @@ -412,8 +423,14 @@ export class SignalProtocolService { } catch (resetError) { console.error("Failed to remove session:", resetError); } - } else if (errorMessage.includes("Tried to decrypt on a sending chain") || errorMessage.includes("No record for device")) { - // These errors indicate the session state is corrupted or missing + } else if ( + errorMessage.includes("Tried to decrypt on a sending chain") || + errorMessage.includes("No record for device") || + errorMessage.includes("Message key not found") || + errorMessage.includes("counter was repeated") || + errorMessage.includes("key was not filled") + ) { + // These errors indicate the session state is corrupted, missing, or out of sync // Remove the session so it can be re-established console.warn(`Session state error for sender ${senderId}: ${errorMessage}. Removing session.`); try { diff --git a/frontend/src/utils/crypto/signalStorage.ts b/frontend/src/utils/crypto/signalStorage.ts index 208a7b8..0aa6652 100644 --- a/frontend/src/utils/crypto/signalStorage.ts +++ b/frontend/src/utils/crypto/signalStorage.ts @@ -46,7 +46,9 @@ function openDB(): Promise { db.createObjectStore("signedPreKeys", { keyPath: "userId" }); } - // Sessions store: key = userId + deviceId, value = { userId, deviceId, record } + // Sessions store: key = userId + recipientId, value = { userId, recipientId, deviceId, record } + // Note: recipientId is stored in the deviceId field for backward compatibility + // The actual deviceId is always 1 for now if (!db.objectStoreNames.contains("sessions")) { const sessionsStore = db.createObjectStore("sessions", { keyPath: ["userId", "deviceId"] }); sessionsStore.createIndex("userId", "userId", { unique: false }); @@ -115,6 +117,19 @@ function toUint8Array(ab: ArrayBuffer | Uint8Array): Uint8Array { return new Uint8Array(ab); } +// Global session sync callback - set by sessionSync service +let sessionSyncCallback: ((address: string, record: string) => Promise) | null = null; +// Flag to prevent sync callback during restoration (to avoid re-uploading restored sessions) +let isRestoring = false; + +export function setSessionSyncCallback(callback: ((address: string, record: string) => Promise) | null): void { + sessionSyncCallback = callback; +} + +export function setRestoring(restoring: boolean): void { + isRestoring = restoring; +} + export class SignalProtocolStorage implements StorageType { private userId: string; @@ -322,51 +337,142 @@ export class SignalProtocolStorage implements StorageType { // Session Management async loadSession(encodedAddress: string): Promise { - // encodedAddress format: "userId.deviceId" + // encodedAddress format: "recipientId.deviceId" (from Signal Protocol) + // recipientId is the other user's ID, deviceId is always 1 for now const parts = encodedAddress.split("."); - const deviceId = parts.length > 1 ? parts[1] : encodedAddress; + const recipientId = parts[0]; // First part is the recipient's user ID + // Load using recipientId as the key (stored in deviceId field for backward compatibility) + // Ensure we search with string to match how we stored it const store = await getStore("sessions"); const result = await new Promise((resolve, reject) => { - const request = store.get([this.userId, deviceId]); + const request = store.get([this.userId, String(recipientId)]); request.onsuccess = () => { const data = request.result; - resolve(data ? data.record : undefined); + if (data && data.record && typeof data.record === "string" && data.record.length > 0) { + resolve(data.record); + } else { + // Try with number if string didn't work (backward compatibility) + if (!data && !isNaN(Number(recipientId))) { + const numRequest = store.get([this.userId, Number(recipientId)]); + numRequest.onsuccess = () => { + const numData = numRequest.result; + if (numData && numData.record && typeof numData.record === "string" && numData.record.length > 0) { + resolve(numData.record); + } else { + console.warn(`Session record missing or invalid for recipient ${recipientId} (address: ${encodedAddress})`); + resolve(undefined); + } + }; + numRequest.onerror = () => { + console.warn(`Session record missing for recipient ${recipientId} (address: ${encodedAddress})`); + resolve(undefined); + }; + } else { + console.warn(`Session record missing or invalid for recipient ${recipientId} (address: ${encodedAddress})`); + resolve(undefined); + } + } + }; + request.onerror = () => { + console.error(`Failed to load session for recipient ${recipientId}:`, request.error); + reject(request.error); }; - request.onerror = () => reject(request.error); }); return result; } async storeSession(encodedAddress: string, record: string): Promise { - // encodedAddress format: "userId.deviceId" + // encodedAddress format: "recipientId.deviceId" (from Signal Protocol) + // recipientId is the other user's ID, deviceId is always 1 for now const parts = encodedAddress.split("."); - const deviceId = parts.length > 1 ? parts[1] : encodedAddress; + const recipientId = parts[0]; // First part is the recipient's user ID + // Validate record + if (!record || typeof record !== "string" || record.length === 0) { + console.warn(`Invalid session record for address ${encodedAddress}`); + return; + } + + // Store with recipientId as the key (using deviceId field for backward compatibility) + // Ensure recipientId is stored as string to match how we load it const store = await getStore("sessions", "readwrite"); await new Promise((resolve, reject) => { const request = store.put({ userId: this.userId, - deviceId: deviceId, + deviceId: String(recipientId), // Store recipientId as string in deviceId field record: record }); - request.onsuccess = () => resolve(); + request.onsuccess = () => { + resolve(); + // If session sync callback is set and we're not restoring, upload to server in background (non-blocking) + // Do this AFTER resolve() to ensure storage completes even if sync fails + if (sessionSyncCallback && !isRestoring) { + // Use setTimeout to make it truly async and non-blocking + setTimeout(() => { + sessionSyncCallback!(encodedAddress, record).then(() => { + console.log(`Session synced to server for ${encodedAddress}`); + }).catch(err => { + console.error(`Failed to sync session to server for ${encodedAddress}:`, err); + }); + }, 0); + } + }; request.onerror = () => reject(request.error); }); } async removeSession(encodedAddress: string): Promise { - // encodedAddress format: "userId.deviceId" + // encodedAddress format: "recipientId.deviceId" (from Signal Protocol) + // recipientId is the other user's ID, deviceId is always 1 for now const parts = encodedAddress.split("."); - const deviceId = parts.length > 1 ? parts[1] : encodedAddress; + const recipientId = parts[0]; // First part is the recipient's user ID + // Remove using recipientId as the key (stored in deviceId field for backward compatibility) const store = await getStore("sessions", "readwrite"); await new Promise((resolve, reject) => { - const request = store.delete([this.userId, deviceId]); + const request = store.delete([this.userId, recipientId]); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } + + /** + * Get all sessions for this user + * Returns array of { address, record } where address is "recipientId.deviceId" + * Note: In IndexedDB, deviceId field actually stores the recipientId from the Signal Protocol address + */ + async getAllSessions(): Promise> { + const store = await getStore("sessions"); + const sessions: Array<{ address: string; record: string }> = []; + + return new Promise((resolve, reject) => { + const request = store.index("userId").openCursor(IDBKeyRange.only(this.userId)); + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + const data = cursor.value; + // In Signal Protocol, address format is "recipientId.deviceId" + // We stored it with recipientId in the deviceId field (for backward compatibility) + // The actual deviceId is always 1 for now + const recipientId = data.deviceId; // This is actually the recipientId from the address + const deviceId = 1; // Always 1 for now + const address = `${recipientId}.${deviceId}`; + + // Validate that record exists and is a string + if (data.record && typeof data.record === "string" && data.record.length > 0) { + sessions.push({ address, record: data.record }); + } else { + console.warn(`Invalid session record for recipient ${recipientId}:`, data); + } + cursor.continue(); + } else { + resolve(sessions); + } + }; + request.onerror = () => reject(request.error); + }); + } }