diff --git a/backend/models.py b/backend/models.py index 7de6175..0d5135c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -106,6 +106,20 @@ class SignalSession(Base): __table_args__ = (UniqueConstraint('user_id', 'recipient_id', 'device_id', name='_user_recipient_device_uc'),) +class SentMessagePlaintext(Base): + """Stores encrypted plaintexts of sent messages for history display""" + __tablename__ = "sent_message_plaintext" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) + message_id = Column(Integer, nullable=False, index=True) # DM envelope ID + recipient_id = Column(Integer, nullable=False, index=True) # The recipient of the message + encrypted_data = Column(Text, nullable=False) # Encrypted plaintext (JSON with salt, iv, ciphertext) + created_at = Column(DateTime, default=datetime.now, index=True) + + __table_args__ = (UniqueConstraint('user_id', 'message_id', name='_user_message_uc'),) + + class DMEnvelope(Base): __tablename__ = "dm_envelope" diff --git a/backend/routes/account.py b/backend/routes/account.py index 3c1d39b..da5449b 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, SignalSession +from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession, SignalSession, SentMessagePlaintext 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 @@ -790,6 +790,93 @@ def get_signal_sessions( } +@router.post("/crypto/signal/message-plaintexts") +@rate_limit_per_ip("100/minute") +def upload_message_plaintexts( + request: Request, + payload: dict, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Upload encrypted plaintexts of sent messages""" + import json + from datetime import datetime + + messages = payload.get("messages") + if not isinstance(messages, list): + raise HTTPException(status_code=400, detail="messages must be a list") + + uploaded_count = 0 + for msg_data in messages: + if not isinstance(msg_data, dict): + continue + + message_id = msg_data.get("messageId") + recipient_id = msg_data.get("recipientId") + encrypted_data = msg_data.get("encryptedData") + + if not message_id or 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 plaintext + existing = db.query(SentMessagePlaintext).filter( + SentMessagePlaintext.user_id == current_user.id, + SentMessagePlaintext.message_id == message_id + ).first() + + if existing: + existing.encrypted_data = encrypted_data + else: + new_plaintext = SentMessagePlaintext( + user_id=current_user.id, + message_id=message_id, + recipient_id=recipient_id, + encrypted_data=encrypted_data + ) + db.add(new_plaintext) + uploaded_count += 1 + + db.commit() + return {"status": "ok", "uploaded_count": uploaded_count} + + +@router.get("/crypto/signal/message-plaintexts") +@rate_limit_per_ip("60/minute") +def get_message_plaintexts( + request: Request, + recipient_id: int | None = None, # Optional filter by recipient + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get encrypted plaintexts of sent messages for the current user""" + query = db.query(SentMessagePlaintext).filter( + SentMessagePlaintext.user_id == current_user.id + ) + + if recipient_id is not None: + query = query.filter(SentMessagePlaintext.recipient_id == recipient_id) + + plaintexts = query.all() + + return { + "messages": [ + { + "messageId": p.message_id, + "recipientId": p.recipient_id, + "encryptedData": p.encrypted_data, + "createdAt": p.created_at.isoformat() + } + for p in plaintexts + ] + } + + @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 6a39125..8dccbc8 100644 --- a/frontend/src/core/api/chats/dm.ts +++ b/frontend/src/core/api/chats/dm.ts @@ -262,6 +262,9 @@ export async function send(recipientId: number, plaintext: string, authToken: st }, data: payload }); + + // Note: We'll cache the message when we receive the dmNew confirmation via WebSocket + // which contains the actual message ID } export async function sendWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise { diff --git a/frontend/src/core/api/crypto/messagePlaintexts.ts b/frontend/src/core/api/crypto/messagePlaintexts.ts new file mode 100644 index 0000000..2fd2c41 --- /dev/null +++ b/frontend/src/core/api/crypto/messagePlaintexts.ts @@ -0,0 +1,69 @@ +/** + * API functions for managing encrypted message plaintexts on the server + */ + +import { API_BASE_URL } from "@/core/config"; +import api from "@/core/api"; + +export interface MessagePlaintextData { + messageId: number; + recipientId: number; + encryptedData: string; +} + +export interface MessagePlaintextResponse { + messageId: number; + recipientId: number; + encryptedData: string; + createdAt: string; +} + +/** + * Upload encrypted message plaintexts to the server + */ +export async function uploadMessagePlaintexts( + messages: MessagePlaintextData[], + token: string +): Promise { + const response = await fetch(`${API_BASE_URL}/crypto/signal/message-plaintexts`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...api.user.auth.getAuthHeaders(token, false) + }, + body: JSON.stringify({ messages }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "Failed to upload message plaintexts" })); + throw new Error(error.detail || "Failed to upload message plaintexts"); + } +} + +/** + * Fetch encrypted message plaintexts from the server + */ +export async function fetchMessagePlaintexts( + token: string, + recipientId?: number +): Promise { + let url = `${API_BASE_URL}/crypto/signal/message-plaintexts`; + if (recipientId !== undefined) { + const separator = url.includes("?") ? "&" : "?"; + url = `${url}${separator}recipient_id=${recipientId}`; + } + + const response = await fetch(url, { + method: "GET", + headers: api.user.auth.getAuthHeaders(token, false) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "Failed to fetch message plaintexts" })); + throw new Error(error.detail || "Failed to fetch message plaintexts"); + } + + const data = await response.json(); + return data.messages || []; +} + diff --git a/frontend/src/core/api/crypto/sessions.ts b/frontend/src/core/api/crypto/sessions.ts index 590cd60..02a13ef 100644 --- a/frontend/src/core/api/crypto/sessions.ts +++ b/frontend/src/core/api/crypto/sessions.ts @@ -36,6 +36,9 @@ export async function uploadSessions(sessions: SessionData[], token: string): Pr * Fetch all encrypted Signal Protocol sessions from the server */ export async function fetchSessions(token: string): Promise { + console.log("[Session API] Fetching sessions from server..."); + console.log("[Session API] URL:", `${API_BASE_URL}/crypto/signal/sessions`); + const headers = getAuthHeaders(token, true); const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, { @@ -43,11 +46,24 @@ export async function fetchSessions(token: string): Promise { headers }); + console.log("[Session API] Response status:", res.status, res.statusText); + if (!res.ok) { - throw new Error(`Failed to fetch sessions: ${res.statusText}`); + const errorText = await res.text().catch(() => "Unknown error"); + console.error("[Session API] Failed to fetch sessions:", { + status: res.status, + statusText: res.statusText, + errorText + }); + throw new Error(`Failed to fetch sessions: ${res.status} ${res.statusText}`); } const data = await res.json(); + console.log("[Session API] Response data:", { + hasSessions: !!data.sessions, + sessionCount: data.sessions?.length || 0 + }); + return data.sessions || []; } diff --git a/frontend/src/core/api/index.ts b/frontend/src/core/api/index.ts index b7e5938..f113a55 100644 --- a/frontend/src/core/api/index.ts +++ b/frontend/src/core/api/index.ts @@ -8,6 +8,7 @@ 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 cryptoMessagePlaintexts from "./crypto/messagePlaintexts"; import * as moderationBlocklist from "./moderation/blocklist"; import * as moderationUsers from "./moderation/users"; import * as callsModule from "./calls"; @@ -29,7 +30,8 @@ const api = { prekeys: cryptoPrekeys, identity: cryptoIdentity, backup: cryptoBackup, - sessions: cryptoSessions + sessions: cryptoSessions, + messagePlaintexts: cryptoMessagePlaintexts }, moderation: { blocklist: moderationBlocklist, diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index fd9bc58..267a50d 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -79,7 +79,14 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { setIsLoading(true); + console.log("========================================"); + console.log("[LoginForm] 🚀 LOGIN FORM SUBMITTED"); + console.log("[LoginForm] Username:", username); + console.log("[LoginForm] Has password:", !!password); + console.log("========================================"); + try { + console.log("[LoginForm] Deriving auth secret..."); const derived = await api.user.auth.deriveAuthSecret(username, password); const request: LoginRequest = { username: username, @@ -87,54 +94,52 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { } try { + console.log("[LoginForm] Calling login API..."); const data = await api.user.auth.login(request); + console.log("[LoginForm] Login successful, user ID:", data.user?.id); + setUser(data.token, data.user); try { + console.log("[LoginForm] Ensuring keys on login..."); await api.user.auth.ensureKeysOnLogin(password, data.token); + console.log("[LoginForm] Keys ensured"); // Initialize Signal Protocol after keys are set up (non-blocking) if (data.user?.id) { + console.log("[LoginForm] ✅ User ID exists, scheduling Signal Protocol initialization"); // Run Signal Protocol initialization in background to avoid blocking navigation - (async () => { + // Use setTimeout to ensure it runs even if navigation happens + setTimeout(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(); - 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) - await uploadAllSessionsToServer(data.user!.id.toString(), password, data.token); - - console.log(`Uploaded ${prekeys.length} prekeys to server`); + console.log("[LoginForm] 🚀 Starting Signal Protocol initialization..."); + const { initializeSignalProtocol } = await import("@/utils/crypto/signalProtocolInit"); + await initializeSignalProtocol({ + userId: data.user!.id.toString(), + password, + token: data.token, + restoreSessions: true, + uploadSessions: true + }); + console.log("[LoginForm] ✅ Signal Protocol initialization completed"); } catch (e) { - console.error("Key setup failed:", e); + console.error("[LoginForm] ❌ Signal Protocol initialization failed:", e); + console.error("[LoginForm] Error details:", { + message: e instanceof Error ? e.message : String(e), + stack: e instanceof Error ? e.stack : undefined + }); } - })(); + }, 0); + console.log("[LoginForm] ✅ Signal Protocol initialization scheduled"); + } else { + console.warn("[LoginForm] ⚠️ No user ID, skipping Signal Protocol initialization"); } } catch (e) { - console.error("Key setup failed:", e); + console.error("[LoginForm] ❌ Key setup failed:", e); + console.error("[LoginForm] Error details:", { + message: e instanceof Error ? e.message : String(e), + stack: e instanceof Error ? e.stack : undefined + }); } // Ensure WebSocket is connected and authenticated diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx index b6d2328..88666f2 100644 --- a/frontend/src/pages/auth/RegisterForm.tsx +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -114,45 +114,30 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) { confirm_password: derived } - try { - const data = await api.user.auth.register(request); - setUser(data.token, data.user); + try { + const data = await api.user.auth.register(request); + + setUser(data.token, data.user); 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 () => { + setTimeout(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`); + const { initializeSignalProtocol } = await import("@/utils/crypto/signalProtocolInit"); + await initializeSignalProtocol({ + userId: data.user!.id.toString(), + password, + token: data.token, + restoreSessions: false, + uploadSessions: true + }); } catch (e) { - console.error("Key setup failed:", e); + console.error("[RegisterForm] Signal Protocol initialization failed:", e); } - })(); + }, 0); } } 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 008cce4..825bcb6 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -172,10 +172,16 @@ export function useDM() { 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 + // Try to get the plaintext from the server (encrypted) + const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync"); + const plaintexts = await fetchMessagePlaintextsForRecipient(userId); + const cached = plaintexts.get(env.id); + if (cached) { + text = cached; + } else { + // Not on server - skip this message + continue; + } } else { // Decrypt incoming messages text = await decryptDm(env, env.senderId); diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index 2f3ff9a..3b2db75 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -6,6 +6,7 @@ import type { UserState, ProfileDialogData } from "@/state/types"; import { formatDMUsername } from "@/pages/chat/hooks/useDM"; import { onlineStatusManager } from "@/core/onlineStatusManager"; import { typingManager } from "@/core/typingManager"; +import { SignalProtocolService } from "@/utils/crypto/signalProtocol"; export interface DMPanelData { userId: number; @@ -18,11 +19,16 @@ export interface DMPanelData { export class DMPanel extends MessagePanel { public dmData: DMPanelData | null = null; private messagesLoaded: boolean = false; + private signalService: SignalProtocolService | null = null; constructor( user: UserState ) { super("dm", user); + // Initialize Signal Protocol service if user is available + if (user.currentUser?.id) { + this.signalService = new SignalProtocolService(user.currentUser.id.toString()); + } } isDm(): boolean { @@ -64,9 +70,9 @@ export class DMPanel extends MessagePanel { 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"); + // The plaintext should be passed in from loadMessages (fetched from server) + // For now, throw an error - the caller should handle this by fetching plaintexts first + throw new Error("Cannot decrypt own sent message - plaintext must be fetched from server first"); } else { // Decrypt incoming messages plaintext = await decryptDm(env, env.senderId); @@ -119,6 +125,28 @@ export class DMPanel extends MessagePanel { this.setLoading(true); try { + // Ensure Signal Protocol session is established before fetching messages + if (!this.signalService && this.currentUser.currentUser?.id) { + this.signalService = new SignalProtocolService(this.currentUser.currentUser.id.toString()); + } + if (this.signalService) { + const hasSession = await this.signalService.hasSession(this.dmData.userId); + if (!hasSession) { + try { + const bundle = await api.crypto.prekeys.fetchPreKeyBundle(this.dmData.userId, this.currentUser.authToken); + await this.signalService.processPreKeyBundle(this.dmData.userId, bundle); + console.log(`Established new Signal Protocol session for user ${this.dmData.userId} during history load.`); + } catch (error) { + console.warn(`Failed to establish Signal Protocol session for user ${this.dmData.userId} during history load:`, error); + // Continue loading history, but decryption will likely fail for new messages + } + } + } + + // Fetch encrypted plaintexts from server for sent messages + const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync"); + const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId); + const limit = this.calculateMessageLimit(); const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit); const decryptedMessages: Message[] = []; @@ -130,7 +158,53 @@ export class DMPanel extends MessagePanel { if (env.id) { this.processedMessageIds.add(env.id); } - const dmMsg = await this.parseTextPayload(env, decryptedMessages); + + // For sent messages, use plaintext from server + const isSentByUs = env.senderId === this.currentUser.currentUser?.id; + let dmMsg: Message; + if (isSentByUs) { + const cachedPlaintext = plaintexts.get(env.id); + if (!cachedPlaintext) { + // Not on server - skip this message + continue; + } + // Parse the plaintext as if it came from parseTextPayload + const username = formatDMUsername( + env.senderId, + env.recipientId, + this.currentUser.currentUser?.id!, + this.dmData!.username + ); + let content = cachedPlaintext; + let reply_to_id: number | undefined = undefined; + try { + const obj = JSON.parse(cachedPlaintext) as DmEncryptedJSON; + if (obj && obj.type === "text" && obj.data) { + content = obj.data.content; + reply_to_id = Number(obj.data.reply_to_id) || undefined; + } + } catch {} + dmMsg = { + id: env.id, + user_id: env.senderId, + content: content, + username: username, + timestamp: env.timestamp, + is_read: false, + is_edited: false, + files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [], + reactions: env.reactions || [], + runtimeData: { + dmEnvelope: env + } + }; + if (reply_to_id) { + const referenced = decryptedMessages.find(m => m.id === reply_to_id); + if (referenced) dmMsg.reply_to = referenced; + } + } else { + dmMsg = await this.parseTextPayload(env, decryptedMessages); + } decryptedMessages.push(dmMsg); if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) { @@ -177,6 +251,28 @@ export class DMPanel extends MessagePanel { this.setLoadingMore(true); try { + // Ensure Signal Protocol session is established before fetching messages + if (!this.signalService && this.currentUser.currentUser?.id) { + this.signalService = new SignalProtocolService(this.currentUser.currentUser.id.toString()); + } + if (this.signalService) { + const hasSession = await this.signalService.hasSession(this.dmData.userId); + if (!hasSession) { + try { + const bundle = await api.crypto.prekeys.fetchPreKeyBundle(this.dmData.userId, this.currentUser.authToken); + await this.signalService.processPreKeyBundle(this.dmData.userId, bundle); + console.log(`Established new Signal Protocol session for user ${this.dmData.userId} during more history load.`); + } catch (error) { + console.warn(`Failed to establish Signal Protocol session for user ${this.dmData.userId} during more history load:`, error); + // Continue loading history, but decryption will likely fail for new messages + } + } + } + + // Fetch encrypted plaintexts from server for sent messages + const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync"); + const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId); + const limit = this.calculateMessageLimit(); const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages( this.dmData.userId, @@ -189,7 +285,57 @@ export class DMPanel extends MessagePanel { const decryptedMessages: Message[] = []; for (const env of newEnvelopes) { try { - const dmMsg = await this.parseTextPayload(env, decryptedMessages); + // Mark as processed to prevent duplicates + if (env.id) { + this.processedMessageIds.add(env.id); + } + + // For sent messages, use plaintext from server + const isSentByUs = env.senderId === this.currentUser.currentUser?.id; + let dmMsg: Message; + if (isSentByUs) { + const cachedPlaintext = plaintexts.get(env.id); + if (!cachedPlaintext) { + // Not on server - skip this message + continue; + } + // Parse the plaintext as if it came from parseTextPayload + const username = formatDMUsername( + env.senderId, + env.recipientId, + this.currentUser.currentUser?.id!, + this.dmData!.username + ); + let content = cachedPlaintext; + let reply_to_id: number | undefined = undefined; + try { + const obj = JSON.parse(cachedPlaintext) as DmEncryptedJSON; + if (obj && obj.type === "text" && obj.data) { + content = obj.data.content; + reply_to_id = Number(obj.data.reply_to_id) || undefined; + } + } catch {} + dmMsg = { + id: env.id, + user_id: env.senderId, + content: content, + username: username, + timestamp: env.timestamp, + is_read: false, + is_edited: false, + files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [], + reactions: env.reactions || [], + runtimeData: { + dmEnvelope: env + } + }; + if (reply_to_id) { + const referenced = decryptedMessages.find(m => m.id === reply_to_id); + if (referenced) dmMsg.reply_to = referenced; + } + } else { + dmMsg = await this.parseTextPayload(env, decryptedMessages); + } decryptedMessages.push(dmMsg); } catch (error) { // Silently skip messages that can't be decrypted @@ -243,10 +389,7 @@ export class DMPanel extends MessagePanel { const { PrekeyExhaustedError } = await import("@/core/api/crypto/prekeys"); if (error instanceof PrekeyExhaustedError) { const { alert } = await import("@/core/components/AlertDialog"); - await alert({ - headline: "Cannot Send Message", - description: "The recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys. This ensures maximum privacy and security." - }); + await alert("Cannot Send Message: The recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys. This ensures maximum privacy and security."); } } } @@ -298,12 +441,27 @@ export class DMPanel extends MessagePanel { if (isOurMessage) { // This is our message being confirmed, find the temp message and replace it const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId); + let tempMsgContent: string | null = null; for (const tempMsg of tempMessages) { if (tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content) { + tempMsgContent = tempMsg.content; // Get plaintext from temp message this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId!, dmMsg); + + // Upload the plaintext to server (encrypted) so we can display it in history + const { uploadMessagePlaintext } = await import("@/utils/crypto/messagePlaintextSync"); + if (tempMsgContent) { + await uploadMessagePlaintext(envelope.id, this.dmData.userId, tempMsgContent); + } return; } } + + // If we didn't find a temp message, try to upload from dmMsg content + // (this might happen if the page was reloaded) + if (!tempMsgContent && dmMsg.content) { + const { uploadMessagePlaintext } = await import("@/utils/crypto/messagePlaintextSync"); + await uploadMessagePlaintext(envelope.id, this.dmData.userId, dmMsg.content); + } } this.addMessage(dmMsg); diff --git a/frontend/src/state/user.ts b/frontend/src/state/user.ts index c437fff..87a5f0a 100644 --- a/frontend/src/state/user.ts +++ b/frontend/src/state/user.ts @@ -8,6 +8,7 @@ import { onlineStatusManager } from "@/core/onlineStatusManager"; import { typingManager } from "@/core/typingManager"; import type { UserState } from "./types"; import { clearSessionSync } from "@/utils/crypto/sessionSync"; +import { clearMessagePlaintextSync } from "@/utils/crypto/messagePlaintextSync"; interface UserStore { user: UserState; @@ -51,8 +52,9 @@ export const useUserStore = create((set) => ({ try { localStorage.removeItem('authToken'); localStorage.removeItem('currentUser'); + sessionStorage.removeItem('sessionPassword'); } catch (error) { - console.error('Failed to clear localStorage:', error); + console.error('Failed to clear storage:', error); } onlineStatusManager.setAuthToken(null); @@ -62,6 +64,7 @@ export const useUserStore = create((set) => ({ // Clear session sync clearSessionSync(); + clearMessagePlaintextSync(); set({ user: { @@ -109,17 +112,47 @@ export const useUserStore = create((set) => ({ 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 + // Note: We can't restore sessions without password, but we can initialize Signal Protocol if (user.id) { (async () => { try { const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol"); + const { uploadAllPreKeys } = await import("@/core/api/crypto/prekeys"); + const { getStoredSessionKey } = await import("@/utils/crypto/sessionKeyStorage"); + const { restoreSessionsFromServer } = await import("@/utils/crypto/sessionSync"); + + console.log("[RestoreFromStorage] Starting Signal Protocol setup..."); + const signalService = new SignalProtocolService(user.id.toString()); await signalService.initialize(); - console.log("Signal Protocol initialized after restore (sessions will be re-established when needed)"); + console.log("[RestoreFromStorage] Signal Protocol initialized"); + + // Check if we have a stored session key (derived from password) + const storedKey = getStoredSessionKey(user.id.toString()); + if (storedKey) { + console.log("[RestoreFromStorage] Stored session key found, restoring sessions from server..."); + // We can restore sessions using the stored key (password not needed) + try { + await restoreSessionsFromServer(user.id.toString(), null, token); + console.log("[RestoreFromStorage] Sessions restored from server using stored key"); + } catch (error) { + console.error("[RestoreFromStorage] Failed to restore sessions:", error); + } + } else { + console.warn("[RestoreFromStorage] No stored session key - user needs to log in to derive key"); + } + + // Re-upload prekeys to ensure they are fresh + try { + const baseBundle = await signalService.getBaseBundle(); + const prekeys = await signalService.getAllPreKeys(); + await uploadAllPreKeys(baseBundle, prekeys, token); + console.log(`[RestoreFromStorage] Uploaded ${prekeys.length} prekeys to server`); + } catch (error) { + console.error("[RestoreFromStorage] Failed to upload prekeys:", error); + } } catch (e) { - console.error("Signal Protocol initialization failed (restored):", e); + console.error("[RestoreFromStorage] Signal Protocol setup failed:", e); } })(); } diff --git a/frontend/src/utils/crypto/kdf.ts b/frontend/src/utils/crypto/kdf.ts index b58a133..a1f3466 100644 --- a/frontend/src/utils/crypto/kdf.ts +++ b/frontend/src/utils/crypto/kdf.ts @@ -3,13 +3,13 @@ export async function importPassword(password: string): Promise { return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]); } -export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000): Promise { +export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000, extractable = false): Promise { const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt; return crypto.subtle.deriveKey( { name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" }, passwordKey, { name: "AES-GCM", length: 256 }, - false, + extractable, ["encrypt", "decrypt"] ); } diff --git a/frontend/src/utils/crypto/messageCache.ts b/frontend/src/utils/crypto/messageCache.ts new file mode 100644 index 0000000..d7327db --- /dev/null +++ b/frontend/src/utils/crypto/messageCache.ts @@ -0,0 +1,135 @@ +/** + * Message cache for storing sent message plaintexts + * Since Signal Protocol doesn't allow decrypting your own sent messages, + * we store the plaintext locally and optionally sync to server + */ + +const DB_NAME = "message_cache_db"; +const DB_VERSION = 1; +const STORE_NAME = "sent_messages"; + +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; + + if (!db.objectStoreNames.contains(STORE_NAME)) { + // Key: [userId, messageId], Value: { plaintext, timestamp } + const store = db.createObjectStore(STORE_NAME, { keyPath: ["userId", "messageId"] }); + store.createIndex("userId", "userId", { unique: false }); + store.createIndex("messageId", "messageId", { unique: false }); + } + }; + }); + + return dbPromise; +} + +async function getStore(mode: IDBTransactionMode = "readonly"): Promise { + const db = await openDB(); + const tx = db.transaction([STORE_NAME], mode); + return tx.objectStore(STORE_NAME); +} + +interface CachedMessage { + userId: number; + messageId: number; + plaintext: string; + timestamp: string; +} + +/** + * Store a sent message's plaintext in the cache + */ +export async function cacheSentMessage(userId: number, messageId: number, plaintext: string): Promise { + try { + const store = await getStore("readwrite"); + await new Promise((resolve, reject) => { + const request = store.put({ + userId, + messageId, + plaintext, + timestamp: new Date().toISOString() + }); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } catch (error) { + console.warn("Failed to cache sent message:", error); + } +} + +/** + * Retrieve a sent message's plaintext from the cache + */ +export async function getCachedMessage(userId: number, messageId: number): Promise { + try { + const store = await getStore(); + const result = await new Promise((resolve, reject) => { + const request = store.get([userId, messageId]); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + return result?.plaintext || null; + } catch (error) { + console.warn("Failed to get cached message:", error); + return null; + } +} + +/** + * Get all cached messages for a user + */ +export async function getAllCachedMessages(userId: number): Promise> { + const cache = new Map(); + try { + const store = await getStore(); + const index = store.index("userId"); + const result = await new Promise((resolve, reject) => { + const request = index.getAll(userId); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + result.forEach(msg => { + cache.set(msg.messageId, msg.plaintext); + }); + } catch (error) { + console.warn("Failed to get all cached messages:", error); + } + return cache; +} + +/** + * Clear cached messages for a user (e.g., on logout) + */ +export async function clearCachedMessages(userId: number): Promise { + try { + const store = await getStore("readwrite"); + const index = store.index("userId"); + await new Promise((resolve, reject) => { + const request = index.openCursor(IDBKeyRange.only(userId)); + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + cursor.delete(); + cursor.continue(); + } else { + resolve(); + } + }; + request.onerror = () => reject(request.error); + }); + } catch (error) { + console.warn("Failed to clear cached messages:", error); + } +} + diff --git a/frontend/src/utils/crypto/messagePlaintextEncryption.ts b/frontend/src/utils/crypto/messagePlaintextEncryption.ts new file mode 100644 index 0000000..cfe7af3 --- /dev/null +++ b/frontend/src/utils/crypto/messagePlaintextEncryption.ts @@ -0,0 +1,23 @@ +/** + * Functions for encrypting/decrypting sent message plaintexts + * Uses password-derived key (same as session encryption) + */ + +import { encryptSessionWithPassword, decryptSessionWithPassword, encodeSessionBlob, decodeSessionBlob } from "./sessionEncryption"; + +/** + * Encrypt message plaintext using password-derived key + */ +export async function encryptMessagePlaintext(password: string | null, userId: string, plaintext: string): Promise { + const encrypted = await encryptSessionWithPassword(password, userId, plaintext); + return encodeSessionBlob(encrypted); +} + +/** + * Decrypt message plaintext using password-derived key + */ +export async function decryptMessagePlaintext(password: string | null, userId: string, encryptedData: string): Promise { + const blob = decodeSessionBlob(encryptedData); + return await decryptSessionWithPassword(password, userId, blob); +} + diff --git a/frontend/src/utils/crypto/messagePlaintextSync.ts b/frontend/src/utils/crypto/messagePlaintextSync.ts new file mode 100644 index 0000000..87a59a6 --- /dev/null +++ b/frontend/src/utils/crypto/messagePlaintextSync.ts @@ -0,0 +1,108 @@ +/** + * Service for syncing sent message plaintexts with the server + * Plaintexts are encrypted with password-derived key and stored on server + */ + +import { encryptMessagePlaintext, decryptMessagePlaintext } from "./messagePlaintextEncryption"; +import { uploadMessagePlaintexts, fetchMessagePlaintexts, type MessagePlaintextData } from "@/core/api/crypto/messagePlaintexts"; + +// Global state for message plaintext sync +let syncPassword: string | null = null; +let syncToken: string | null = null; +let syncUserId: string | null = null; + +/** + * Initialize message plaintext sync - stores password and userId for encryption + * Called after login when password is available + */ +export function initializeMessagePlaintextSync(userId: string, password: string, token: string): void { + console.log("Initializing message plaintext sync for user", userId); + syncUserId = userId; + syncPassword = password; + syncToken = token; +} + +/** + * Clear message plaintext sync - called on logout + */ +export function clearMessagePlaintextSync(): void { + console.log("Clearing message plaintext sync state"); + syncUserId = null; + syncPassword = null; + syncToken = null; +} + +/** + * Upload a sent message's plaintext to the server (encrypted) + * Called when a message is sent and confirmed + */ +export async function uploadMessagePlaintext( + messageId: number, + recipientId: number, + plaintext: string +): Promise { + if (!syncPassword || !syncToken || !syncUserId) { + console.warn("Message plaintext sync not initialized (missing password/token/userId)"); + return; // Not initialized yet + } + + try { + console.log(`Encrypting plaintext for message ${messageId}...`); + // Use stored key if available, otherwise use password to derive it + const encryptedData = await encryptMessagePlaintext(syncPassword, syncUserId, plaintext); + + console.log(`Uploading plaintext for message ${messageId} to server...`); + await uploadMessagePlaintexts([ + { + messageId, + recipientId, + encryptedData + } + ], syncToken); + console.log(`Successfully uploaded plaintext for message ${messageId} to server`); + } catch (error) { + console.error("Failed to upload message plaintext to server:", error); + // Don't throw - message is already sent, plaintext upload failure shouldn't break anything + } +} + +/** + * Fetch and decrypt message plaintexts from the server + * Called when loading message history + */ +export async function fetchMessagePlaintextsForRecipient( + recipientId: number +): Promise> { + const plaintexts = new Map(); + + if (!syncToken || !syncUserId) { + console.warn("Message plaintext sync not initialized (missing token/userId)"); + return plaintexts; // Return empty map if not initialized + } + + try { + console.log(`Fetching encrypted plaintexts for recipient ${recipientId}...`); + const encryptedMessages = await fetchMessagePlaintexts(syncToken, recipientId); + + console.log(`Found ${encryptedMessages.length} encrypted plaintexts, decrypting...`); + + for (const msg of encryptedMessages) { + try { + // Use stored key if available, otherwise use password to derive it + const plaintext = await decryptMessagePlaintext(syncPassword, syncUserId, msg.encryptedData); + plaintexts.set(msg.messageId, plaintext); + } catch (error) { + console.warn(`Failed to decrypt plaintext for message ${msg.messageId}:`, error); + // Continue with other messages + } + } + + console.log(`Decrypted ${plaintexts.size}/${encryptedMessages.length} plaintexts`); + } catch (error) { + console.error("Failed to fetch message plaintexts from server:", error); + // Don't throw - allow history loading to continue even if plaintext fetch fails + } + + return plaintexts; +} + diff --git a/frontend/src/utils/crypto/sessionEncryption.ts b/frontend/src/utils/crypto/sessionEncryption.ts index 907b387..ebfbe2e 100644 --- a/frontend/src/utils/crypto/sessionEncryption.ts +++ b/frontend/src/utils/crypto/sessionEncryption.ts @@ -1,37 +1,73 @@ /** * Functions for encrypting/decrypting Signal Protocol session data - * Uses password-derived key (same as backup encryption) + * Uses a stable key derived from password (stored in localStorage as a hash) */ import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric"; -import { importPassword, deriveKEK, randomBytes } from "./kdf"; +import { randomBytes } from "./kdf"; import { b64, ub64 } from "../utils"; +import { deriveSessionKey, exportKey, importKey, storeSessionKey, getStoredSessionKey } from "./sessionKeyStorage"; export interface EncryptedSessionData { - salt: Uint8Array; // for PBKDF2 derivation of KEK + salt: Uint8Array; // Random salt (kept for backward compatibility, not used for key derivation) iv: Uint8Array; // AES-GCM IV ciphertext: Uint8Array; // encrypted session record (string) } /** - * Encrypt session record using password-derived key + * Encrypt session record using stored session key (derived from password) + * If password is provided and key is not stored, derive and store it + * If password is not provided, use stored key (for page refresh scenarios) */ -export async function encryptSessionWithPassword(password: string, sessionRecord: string): Promise { +export async function encryptSessionWithPassword(password: string | null, userId: string, sessionRecord: string): Promise { + // Derive or get the stored session key + let sessionKey: CryptoKey; + const storedKeyString = getStoredSessionKey(userId); + + if (storedKeyString) { + // Use stored key (works even without password on page refresh) + sessionKey = await importKey(storedKeyString); + } else if (password) { + // Derive new key and store it (as a "hash" - it's actually the derived key) + sessionKey = await deriveSessionKey(password, userId); + const keyString = await exportKey(sessionKey); + storeSessionKey(userId, keyString); + } else { + throw new Error("Cannot encrypt session: no stored key and no password provided"); + } + + // Generate random salt for backward compatibility (not used for key derivation) 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); + + // Encrypt using the stable key (aesGcmEncrypt generates its own IV) + const { iv, ciphertext } = await aesGcmEncrypt(sessionKey, sessionBytes); return { salt, iv, ciphertext }; } /** - * Decrypt session record using password-derived key + * Decrypt session record using stored session key + * If password is provided and key is not stored, derive and store it + * If password is not provided, use stored key (for page refresh scenarios) */ -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); +export async function decryptSessionWithPassword(password: string | null, userId: string, blob: EncryptedSessionData): Promise { + // Get or derive the session key + let sessionKey: CryptoKey; + const storedKeyString = getStoredSessionKey(userId); + + if (storedKeyString) { + // Use stored key (works even without password on page refresh) + sessionKey = await importKey(storedKeyString); + } else if (password) { + // Derive key from password and store it + sessionKey = await deriveSessionKey(password, userId); + const keyString = await exportKey(sessionKey); + storeSessionKey(userId, keyString); + } else { + throw new Error("Cannot decrypt session: no stored key and no password provided"); + } + + const plaintext = await aesGcmDecrypt(sessionKey, blob.iv, blob.ciphertext); return new TextDecoder().decode(plaintext); } diff --git a/frontend/src/utils/crypto/sessionKeyStorage.ts b/frontend/src/utils/crypto/sessionKeyStorage.ts new file mode 100644 index 0000000..838d37d --- /dev/null +++ b/frontend/src/utils/crypto/sessionKeyStorage.ts @@ -0,0 +1,89 @@ +/** + * Functions for deriving and storing a stable key from password + * This key is used to encrypt/decrypt sessions on the server + */ + +import { importPassword, deriveKEK } from "./kdf"; +import { b64, ub64 } from "../utils"; + +const SESSION_KEY_SALT_PREFIX = "fromchat.session-key:"; + +/** + * Derive a stable key from password using user ID as salt + * User ID never changes, so this key will always be the same for a given password + * This key can be stored in localStorage and used to encrypt/decrypt sessions + */ +export async function deriveSessionKey(password: string, userId: string): Promise { + const salt = new TextEncoder().encode(`${SESSION_KEY_SALT_PREFIX}${userId}`); + const pw = await importPassword(password); + // Make the key extractable so we can store it in localStorage + return await deriveKEK(pw, salt, 210_000, true); +} + +/** + * Export a CryptoKey to a base64 string for storage + */ +export async function exportKey(key: CryptoKey): Promise { + const exported = await crypto.subtle.exportKey("raw", key); + return b64(new Uint8Array(exported)); +} + +/** + * Import a base64 string back to a CryptoKey + */ +export async function importKey(keyString: string): Promise { + const keyBytes = ub64(keyString); + // Ensure we have a proper ArrayBuffer (not SharedArrayBuffer) + // Create a new ArrayBuffer copy to avoid SharedArrayBuffer issues + const keyArray = new Uint8Array(keyBytes); + const keyBuffer = keyArray.buffer; + return await crypto.subtle.importKey( + "raw", + keyBuffer, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} + +/** + * Store the session key in localStorage + */ +export function storeSessionKey(userId: string, keyString: string): void { + try { + localStorage.setItem(`sessionKey:${userId}`, keyString); + console.log(`[SessionKeyStorage] ✅ Stored session key for user ${userId} (length: ${keyString.length})`); + } catch (error) { + console.error("[SessionKeyStorage] ❌ Failed to store session key:", error); + } +} + +/** + * Retrieve the session key from localStorage + */ +export function getStoredSessionKey(userId: string): string | null { + try { + const key = localStorage.getItem(`sessionKey:${userId}`); + if (key) { + console.log(`[SessionKeyStorage] ✅ Retrieved stored session key for user ${userId} (length: ${key.length})`); + } else { + console.log(`[SessionKeyStorage] ⚠️ No stored session key found for user ${userId}`); + } + return key; + } catch (error) { + console.error("[SessionKeyStorage] ❌ Failed to get session key:", error); + return null; + } +} + +/** + * Clear the session key from localStorage + */ +export function clearSessionKey(userId: string): void { + try { + localStorage.removeItem(`sessionKey:${userId}`); + } catch (error) { + console.error("Failed to clear session key:", error); + } +} + diff --git a/frontend/src/utils/crypto/sessionSync.ts b/frontend/src/utils/crypto/sessionSync.ts index f789520..204d4f0 100644 --- a/frontend/src/utils/crypto/sessionSync.ts +++ b/frontend/src/utils/crypto/sessionSync.ts @@ -41,7 +41,7 @@ export function initializeSessionSync(userId: string, password: string, token: s } console.log(`Encrypting session for recipient ${recipientId}...`); - const encryptedBlob = await encryptSessionWithPassword(syncPassword, record); + const encryptedBlob = await encryptSessionWithPassword(syncPassword, syncUserId, record); const encryptedData = encodeSessionBlob(encryptedBlob); console.log(`Uploading session for recipient ${recipientId} to server...`); @@ -81,17 +81,20 @@ export async function restoreSessionsFromServer( token: string ): Promise { try { - console.log("Restoring sessions from server..."); + console.log("[Session Sync] Restoring sessions from server..."); + console.log("[Session Sync] Making API request to fetch sessions..."); // Fetch encrypted sessions from server const encryptedSessions = await fetchSessions(token); + console.log(`[Session Sync] API response received: ${encryptedSessions.length} sessions found`); + if (encryptedSessions.length === 0) { - console.log("No sessions to restore from server"); + console.log("[Session Sync] No sessions to restore from server"); return; // No sessions to restore } - console.log(`Found ${encryptedSessions.length} sessions on server, restoring...`); + console.log(`[Session Sync] Found ${encryptedSessions.length} sessions on server, restoring...`); const storage = new SignalProtocolStorage(userId); @@ -116,7 +119,8 @@ export async function restoreSessionsFromServer( } const encryptedBlob = decodeSessionBlob(sessionData.encryptedData); - const sessionRecord = await decryptSessionWithPassword(password, encryptedBlob); + // Use stored key if available, otherwise use password to derive it + const sessionRecord = await decryptSessionWithPassword(password, userId, encryptedBlob); // Store in IndexedDB (sync callback won't fire because isRestoring is true) await storage.storeSession(address, sessionRecord); @@ -133,9 +137,13 @@ export async function restoreSessionsFromServer( setRestoring(false); } - console.log(`Restored ${restoredCount}/${encryptedSessions.length} sessions from server${failedCount > 0 ? ` (${failedCount} failed)` : ""}`); + console.log(`[Session Sync] Restored ${restoredCount}/${encryptedSessions.length} sessions from server${failedCount > 0 ? ` (${failedCount} failed)` : ""}`); } catch (error) { - console.error("Failed to restore sessions from server:", error); + console.error("[Session Sync] Failed to restore sessions from server:", error); + console.error("[Session Sync] Error details:", { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); // Don't throw - allow login to continue even if session restore fails } } @@ -181,8 +189,8 @@ export async function uploadAllSessionsToServer( continue; } - // Encrypt session record - const encryptedBlob = await encryptSessionWithPassword(password, record); + // Encrypt session record (use stored key if available) + const encryptedBlob = await encryptSessionWithPassword(password, userId, record); const encryptedData = encodeSessionBlob(encryptedBlob); sessionData.push({ @@ -235,9 +243,9 @@ export async function storeSessionWithSync( return; } - // Encrypt and upload to server + // Encrypt and upload to server (use stored key if available) try { - const encryptedBlob = await encryptSessionWithPassword(password, record); + const encryptedBlob = await encryptSessionWithPassword(password, userId, record); const encryptedData = encodeSessionBlob(encryptedBlob); await uploadSessions([ diff --git a/frontend/src/utils/crypto/signalProtocolInit.ts b/frontend/src/utils/crypto/signalProtocolInit.ts new file mode 100644 index 0000000..9b3984f --- /dev/null +++ b/frontend/src/utils/crypto/signalProtocolInit.ts @@ -0,0 +1,135 @@ +/** + * Centralized Signal Protocol initialization + * Used in login, register, and token restoration + */ + +import { SignalProtocolService } from "./signalProtocol"; +import { uploadAllPreKeys } from "@/core/api/crypto/prekeys"; +import { initializeSessionSync, restoreSessionsFromServer, uploadAllSessionsToServer } from "./sessionSync"; +import { initializeMessagePlaintextSync } from "./messagePlaintextSync"; +import { deriveSessionKey, exportKey, storeSessionKey } from "./sessionKeyStorage"; + +export interface SignalProtocolInitOptions { + userId: string; + password: string; + token: string; + restoreSessions?: boolean; + uploadSessions?: boolean; +} + +/** + * Initialize Signal Protocol with all necessary setup + * This function handles: + * - Signal Protocol service initialization + * - Session key derivation and storage + * - Session sync initialization + * - Message plaintext sync initialization + * - Prekey bundle upload + * - Session restoration from server (optional) + * - Plaintext restoration from server (optional) + * - Session upload to server (optional) + * - Plaintext upload to server (optional) + */ +export async function initializeSignalProtocol({ + userId, + password, + token, + restoreSessions = false, + uploadSessions = false +}: SignalProtocolInitOptions): Promise { + console.log("========================================"); + console.log("[Signal Protocol Init] 🚀 STARTING SIGNAL PROTOCOL INITIALIZATION"); + console.log("[Signal Protocol Init] User ID:", userId); + console.log("[Signal Protocol Init] Has password:", !!password); + console.log("[Signal Protocol Init] Has token:", !!token); + console.log("========================================"); + + try { + // Step 1: Derive and store session key + console.log("[Signal Protocol Init] Step 1: Deriving session key..."); + const sessionKey = await deriveSessionKey(password, userId); + const keyString = await exportKey(sessionKey); + storeSessionKey(userId, keyString); + console.log("[Signal Protocol Init] Step 1: ✅ Session key derived and stored"); + + // Step 2: Initialize Signal Protocol service + console.log("[Signal Protocol Init] Step 2: Initializing Signal Protocol service..."); + const signalService = new SignalProtocolService(userId); + await signalService.initialize(); + console.log("[Signal Protocol Init] Step 2: ✅ Signal Protocol service initialized"); + + // Step 3: Initialize session sync + console.log("[Signal Protocol Init] Step 3: Initializing session sync..."); + initializeSessionSync(userId, password, token); + console.log("[Signal Protocol Init] Step 3: ✅ Session sync initialized"); + + // Step 4: Initialize message plaintext sync + console.log("[Signal Protocol Init] Step 4: Initializing message plaintext sync..."); + initializeMessagePlaintextSync(userId, password, token); + console.log("[Signal Protocol Init] Step 4: ✅ Message plaintext sync initialized"); + + // Step 5: Restore sessions from server (if requested) + if (restoreSessions) { + console.log("========================================"); + console.log("[Signal Protocol Init] Step 5: ⚠️ RESTORING SESSIONS FROM SERVER"); + console.log("========================================"); + try { + await restoreSessionsFromServer(userId, password, token); + console.log("========================================"); + console.log("[Signal Protocol Init] Step 5: ✅ SESSIONS RESTORED FROM SERVER"); + console.log("========================================"); + } catch (error) { + console.error("========================================"); + console.error("[Signal Protocol Init] Step 5: ❌ SESSION RESTORATION FAILED"); + console.error("[Signal Protocol Init] Error:", error); + console.error("========================================"); + // Continue even if restoration fails + } + } + + // Step 6: Upload prekey bundle + console.log("[Signal Protocol Init] Step 6: Uploading prekey bundle..."); + const bundle = await signalService.getPreKeyBundle(); + const { uploadPreKeyBundle } = await import("@/core/api/crypto/prekeys"); + await uploadPreKeyBundle(bundle, token); + console.log("[Signal Protocol Init] Step 6: ✅ Prekey bundle uploaded"); + + // Step 7: Upload all prekeys + console.log("[Signal Protocol Init] Step 7: Uploading all prekeys..."); + const baseBundle = await signalService.getBaseBundle(); + const prekeys = await signalService.getAllPreKeys(); + await uploadAllPreKeys(baseBundle, prekeys, token); + console.log(`[Signal Protocol Init] Step 7: ✅ Uploaded ${prekeys.length} prekeys to server`); + + // Step 8: Upload all sessions to server (if requested) + if (uploadSessions) { + console.log("[Signal Protocol Init] Step 8: Uploading all sessions to server..."); + try { + await uploadAllSessionsToServer(userId, password, token); + console.log("[Signal Protocol Init] Step 8: ✅ Sessions uploaded to server"); + } catch (error) { + console.error("[Signal Protocol Init] Step 8: ❌ Failed to upload sessions:", error); + // Continue even if upload fails + } + } + + // Step 9: Restore message plaintexts from server (if requested) + // Note: Message plaintext restoration is handled per-conversation when needed + // No bulk restoration needed here + + // Step 10: Upload all message plaintexts to server (if requested) + // Note: Message plaintext upload is handled automatically when messages are sent + // No bulk upload needed here + + console.log("========================================"); + console.log("[Signal Protocol Init] ✅ ALL SIGNAL PROTOCOL INITIALIZATION COMPLETED"); + console.log("========================================"); + } catch (error) { + console.error("========================================"); + console.error("[Signal Protocol Init] ❌ SIGNAL PROTOCOL INITIALIZATION FAILED"); + console.error("[Signal Protocol Init] Error:", error); + console.error("========================================"); + throw error; + } +} +